feat: show exact error when typing IP address manually

This commit is contained in:
Tien Do Nam
2024-11-04 18:37:56 +01:00
parent 1941e542db
commit a155ebb57d
10 changed files with 192 additions and 84 deletions
+1
View File
@@ -1,5 +1,6 @@
## 1.16.1 (unreleased)
- feat: show exact error message when using IP address dialog or favorite dialog (@Tienisto)
- feat(desktop): highlight file when tapping "Show in folder" (@Tienisto)
- fix(android): properly close app on back gesture (@Tienisto)
@@ -1,7 +1,8 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:common/isolate.dart';
import 'package:common/model/device.dart';
import 'package:common/util/task_runner.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:localsend_app/config/theme.dart';
@@ -9,6 +10,7 @@ import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/provider/last_devices.provider.dart';
import 'package:localsend_app/provider/local_ip_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/widget/dialogs/error_dialog.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
@@ -38,7 +40,7 @@ class _AddressInputDialogState extends State<AddressInputDialog> with Refena {
_InputMode _mode = _InputMode.hashtag;
String _input = '';
bool _fetching = false;
bool _failed = false;
String? _error;
Future<void> _submit(List<String> localIps, int port, [String? candidate]) async {
final List<String> candidates;
@@ -56,35 +58,51 @@ class _AddressInputDialogState extends State<AddressInputDialog> with Refena {
});
final https = ref.read(settingsProvider).https;
final results = TaskRunner<Device?>(
concurrency: 10,
initialTasks: [
for (final ip in candidates)
() => ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
ip: ip,
port: port,
https: https,
)),
],
).stream;
bool found = false;
final deviceCompleter = Completer<void>();
Device? foundDevice;
String? error;
await for (final device in results) {
if (device != null) {
found = true;
if (mounted) {
ref.redux(lastDevicesProvider).dispatch(AddLastDeviceAction(device));
context.pop(device);
}
break;
}
final List<Future<Device>> futures = [
for (final ip in candidates)
() async {
try {
final device = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
ip: ip,
port: port,
https: https,
));
foundDevice = device;
deviceCompleter.complete();
return device;
} catch (e) {
error = e.toString();
rethrow;
}
}(),
];
// Wait until,
// - a device is found
// - all candidates are checked
try {
await Future.any([
deviceCompleter.future,
Future.wait(futures),
]);
} catch (_) {}
if (!mounted) {
return;
}
if (!found && mounted) {
if (foundDevice != null) {
ref.redux(lastDevicesProvider).dispatch(AddLastDeviceAction(foundDevice!));
context.pop(foundDevice);
} else {
setState(() {
_fetching = false;
_failed = true;
_error = error;
});
}
}
@@ -182,10 +200,29 @@ class _AddressInputDialogState extends State<AddressInputDialog> with Refena {
),
),
],
if (_failed)
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
child: Row(
children: [
Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
if (_error != null) ...[
const SizedBox(width: 5),
InkWell(
onTap: () async {
await showDialog(
context: context,
builder: (_) => ErrorDialog(error: _error!),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: Icon(Icons.info, color: Theme.of(context).colorScheme.warning, size: 20),
),
),
],
],
),
),
],
),
+35 -17
View File
@@ -5,6 +5,7 @@ import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/model/persistence/favorite_device.dart';
import 'package:localsend_app/provider/favorites_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/widget/dialogs/error_dialog.dart';
import 'package:localsend_app/widget/dialogs/favorite_edit_dialog.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
@@ -19,7 +20,7 @@ class FavoritesDialog extends StatefulWidget {
class _FavoritesDialogState extends State<FavoritesDialog> with Refena {
bool _fetching = false;
bool _failed = false;
String? _error;
/// Checks if the device is reachable and pops the dialog with the result if it is.
Future<void> _checkConnectionToDevice(FavoriteDevice favorite) async {
@@ -29,24 +30,22 @@ class _FavoritesDialogState extends State<FavoritesDialog> with Refena {
final https = ref.read(settingsProvider).https;
final result = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
ip: favorite.ip,
port: favorite.port,
https: https,
));
if (result == null) {
try {
final result = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
ip: favorite.ip,
port: favorite.port,
https: https,
));
if (mounted) {
context.pop(result);
}
} catch (e) {
setState(() {
_fetching = false;
_failed = true;
_error = e.toString();
});
return;
}
if (!mounted) {
return;
}
context.pop(result);
}
Future<void> _showDeviceDialog([FavoriteDevice? favorite]) async {
@@ -88,10 +87,29 @@ class _FavoritesDialogState extends State<FavoritesDialog> with Refena {
),
],
),
if (_failed)
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
child: Row(
children: [
Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
if (_error != null) ...[
const SizedBox(width: 5),
InkWell(
onTap: () async {
await showDialog(
context: context,
builder: (_) => ErrorDialog(error: _error!),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: Icon(Icons.info, color: Theme.of(context).colorScheme.warning, size: 20),
),
),
],
],
),
),
],
),
@@ -6,6 +6,7 @@ import 'package:localsend_app/gen/strings.g.dart';
import 'package:localsend_app/model/persistence/favorite_device.dart';
import 'package:localsend_app/provider/favorites_provider.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/widget/dialogs/error_dialog.dart';
import 'package:localsend_app/widget/dialogs/favorite_delete_dialog.dart';
import 'package:refena_flutter/refena_flutter.dart';
import 'package:routerino/routerino.dart';
@@ -29,7 +30,7 @@ class _FavoriteEditDialogState extends State<FavoriteEditDialog> with Refena {
final _portController = TextEditingController();
final _aliasController = TextEditingController();
bool _fetching = false;
bool _failed = false;
String? _error;
@override
void initState() {
@@ -110,10 +111,29 @@ class _FavoriteEditDialogState extends State<FavoriteEditDialog> with Refena {
label: Text(t.general.delete),
),
],
if (_failed)
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
child: Row(
children: [
Text(t.general.error, style: TextStyle(color: Theme.of(context).colorScheme.warning)),
if (_error != null) ...[
const SizedBox(width: 5),
InkWell(
onTap: () async {
await showDialog(
context: context,
builder: (_) => ErrorDialog(error: _error!),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5),
child: Icon(Icons.info, color: Theme.of(context).colorScheme.warning, size: 20),
),
),
],
],
),
),
],
),
@@ -157,31 +177,32 @@ class _FavoriteEditDialogState extends State<FavoriteEditDialog> with Refena {
setState(() {
_fetching = true;
});
final result = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
ip: ip,
port: port,
https: https,
));
if (result == null) {
try {
final result = await ref.redux(parentIsolateProvider).dispatchAsyncTakeResult(IsolateTargetHttpDiscoveryAction(
ip: ip,
port: port,
https: https,
));
final name = _aliasController.text.trim();
await ref.redux(favoritesProvider).dispatchAsync(AddFavoriteAction(FavoriteDevice.fromValues(
fingerprint: result.fingerprint,
ip: _ipController.text,
port: int.parse(_portController.text),
alias: name.isEmpty ? result.alias : name,
)));
if (context.mounted) {
context.pop();
}
} catch (e) {
setState(() {
_fetching = false;
_failed = true;
_error = e.toString();
});
return;
}
final name = _aliasController.text.trim();
await ref.redux(favoritesProvider).dispatchAsync(AddFavoriteAction(FavoriteDevice.fromValues(
fingerprint: result.fingerprint,
ip: _ipController.text,
port: int.parse(_portController.text),
alias: name.isEmpty ? result.alias : name,
)));
}
if (context.mounted) {
context.pop();
}
},
child: Text(t.general.confirm),
@@ -21,7 +21,7 @@ class HttpTargetTask {
@internal
Future<void> setupHttpTargetDiscoveryIsolate(
Stream<SendToIsolateData<IsolateTask<HttpTargetTask>>> receiveFromMain,
void Function(IsolateTaskResult<Device?>) sendToMain,
void Function(IsolateTaskResult<Device>) sendToMain,
InitialData initialData,
) async {
await setupChildIsolateHelper(
@@ -30,12 +30,22 @@ Future<void> setupHttpTargetDiscoveryIsolate(
sendToMain: sendToMain,
initialData: initialData,
handler: (ref, task) async {
Object? error;
final device = await ref.read(httpTargetDiscoveryProvider).discover(
ip: task.data.ip,
port: task.data.port,
https: task.data.https,
onError: (url, e) => error = e,
);
sendToMain(IsolateTaskResult(
if (error != null || device == null) {
return sendToMain(IsolateTaskErrorResult(
id: task.id,
error: error?.toString() ?? 'Unknown error',
));
}
sendToMain(IsolateTaskSuccessResult(
id: task.id,
data: device,
));
@@ -1,17 +1,33 @@
import 'package:common/src/isolate/dto/isolate_task.dart';
/// The response data structure from an [IsolateTask].
class IsolateTaskResult<T> {
sealed class IsolateTaskResult<T> {
/// The id of the task to be matched with [IsolateTask.id].
final int id;
IsolateTaskResult._({
required this.id,
});
}
class IsolateTaskSuccessResult<T> extends IsolateTaskResult<T> {
/// The payload of the response.
final T data;
IsolateTaskResult({
required this.id,
IsolateTaskSuccessResult({
required int id,
required this.data,
});
}) : super._(id: id);
}
class IsolateTaskErrorResult<T> extends IsolateTaskResult<T> {
/// The error.
final String error;
IsolateTaskErrorResult({
required int id,
required this.error,
}) : super._(id: id);
}
/// Stream version of [IsolateTaskResult].
+9 -4
View File
@@ -15,7 +15,7 @@ import 'package:refena/refena.dart';
final _idProvider = IdProvider();
class IsolateTargetHttpDiscoveryAction extends AsyncReduxActionWithResult<IsolateController, ParentIsolateState, Device?> {
class IsolateTargetHttpDiscoveryAction extends AsyncReduxActionWithResult<IsolateController, ParentIsolateState, Device> {
final String ip;
final int port;
final bool https;
@@ -27,7 +27,7 @@ class IsolateTargetHttpDiscoveryAction extends AsyncReduxActionWithResult<Isolat
});
@override
Future<(ParentIsolateState, Device?)> reduce() async {
Future<(ParentIsolateState, Device)> reduce() async {
final connection = state.httpTargetDiscovery;
if (connection == null) {
throw StateError('httpTargetDiscovery is not initialized');
@@ -52,11 +52,16 @@ class IsolateTargetHttpDiscoveryAction extends AsyncReduxActionWithResult<Isolat
await for (final result in connection.receiveFromIsolate) {
if (result.id == task.id) {
return (state, result.data);
switch (result) {
case IsolateTaskSuccessResult<Device>():
return (state, result.data);
case IsolateTaskErrorResult<Device>():
throw result.error;
}
}
}
return (state, null);
throw StateError('Unexpected end of stream');
}
}
@@ -24,7 +24,7 @@ const _uploadIsolateCount = 2;
class ParentIsolateState with ParentIsolateStateMappable {
final SyncState syncState;
final IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<HttpScanTask>>>? httpScanDiscovery;
final IsolateConnector<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery;
final IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery;
final IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>? multicastDiscovery;
final List<IsolateConnector<IsolateTaskStreamResult<double>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>> httpUpload;
int get uploadIsolateCount => httpUpload.length;
@@ -86,7 +86,7 @@ class IsolateSetupAction extends AsyncReduxAction<IsolateController, ParentIsola
),
);
final httpTargetDiscovery = await startIsolate<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>, InitialData>(
final httpTargetDiscovery = await startIsolate<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>, InitialData>(
task: setupHttpTargetDiscoveryIsolate,
param: InitialData(
syncState: state.syncState,
@@ -28,9 +28,9 @@ class ParentIsolateStateMapper extends ClassMapperBase<ParentIsolateState> {
v.httpScanDiscovery;
static const Field<ParentIsolateState, IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<HttpScanTask>>>>
_f$httpScanDiscovery = Field('httpScanDiscovery', _$httpScanDiscovery);
static IsolateConnector<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? _$httpTargetDiscovery(ParentIsolateState v) =>
static IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? _$httpTargetDiscovery(ParentIsolateState v) =>
v.httpTargetDiscovery;
static const Field<ParentIsolateState, IsolateConnector<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>>>
static const Field<ParentIsolateState, IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>>
_f$httpTargetDiscovery = Field('httpTargetDiscovery', _$httpTargetDiscovery);
static IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>? _$multicastDiscovery(ParentIsolateState v) => v.multicastDiscovery;
static const Field<ParentIsolateState, IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>> _f$multicastDiscovery =
@@ -113,7 +113,7 @@ abstract class ParentIsolateStateCopyWith<$R, $In extends ParentIsolateState, $O
$R call(
{SyncState? syncState,
IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<HttpScanTask>>>? httpScanDiscovery,
IsolateConnector<IsolateTaskResult<Device?>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery,
IsolateConnector<IsolateTaskResult<Device>, SendToIsolateData<IsolateTask<HttpTargetTask>>>? httpTargetDiscovery,
IsolateConnector<Device, SendToIsolateData<MulticastAnnouncementTask>>? multicastDiscovery,
List<IsolateConnector<IsolateTaskStreamResult<double>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>>? httpUpload});
ParentIsolateStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t);
@@ -38,7 +38,7 @@ class HttpTargetDiscoveryService {
final dto = InfoDto.fromJson(response.data);
return dto.toDevice(ip, port, https);
} on DioException catch (e) {
onError?.call(url, e.error);
onError?.call(url, e);
return null;
} catch (e) {
onError?.call(url, e);