mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat: add device detail page
This commit is contained in:
@@ -207,6 +207,26 @@
|
|||||||
"selectedFilesPage": {
|
"selectedFilesPage": {
|
||||||
"deleteAll": "Delete all"
|
"deleteAll": "Delete all"
|
||||||
},
|
},
|
||||||
|
"deviceDetailsPage": {
|
||||||
|
"title": "Device Details",
|
||||||
|
"favorite": "Favorite",
|
||||||
|
"verify": "Verify",
|
||||||
|
"info": {
|
||||||
|
"name": "Name",
|
||||||
|
"address": "Address"
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"title": "Logs",
|
||||||
|
"empty": "No logs available.",
|
||||||
|
"discovered": "Discovered via {protocol} ({host})",
|
||||||
|
"updated": "Updated via {protocol} ({host})"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifyPage": {
|
||||||
|
"title": "Verify",
|
||||||
|
"icons": "Icons",
|
||||||
|
"raw": "Raw"
|
||||||
|
},
|
||||||
"receivePage": {
|
"receivePage": {
|
||||||
"subTitle": {
|
"subTitle": {
|
||||||
"one": "wants to send you a file",
|
"one": "wants to send you a file",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 55
|
/// Locales: 55
|
||||||
/// Strings: 18560 (337 per locale)
|
/// Strings: 18572 (337 per locale)
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||||||
late final Translations$receiveHistoryPage$en receiveHistoryPage = Translations$receiveHistoryPage$en.internal(_root);
|
late final Translations$receiveHistoryPage$en receiveHistoryPage = Translations$receiveHistoryPage$en.internal(_root);
|
||||||
late final Translations$apkPickerPage$en apkPickerPage = Translations$apkPickerPage$en.internal(_root);
|
late final Translations$apkPickerPage$en apkPickerPage = Translations$apkPickerPage$en.internal(_root);
|
||||||
late final Translations$selectedFilesPage$en selectedFilesPage = Translations$selectedFilesPage$en.internal(_root);
|
late final Translations$selectedFilesPage$en selectedFilesPage = Translations$selectedFilesPage$en.internal(_root);
|
||||||
|
late final Translations$deviceDetailsPage$en deviceDetailsPage = Translations$deviceDetailsPage$en.internal(_root);
|
||||||
|
late final Translations$verifyPage$en verifyPage = Translations$verifyPage$en.internal(_root);
|
||||||
late final Translations$receivePage$en receivePage = Translations$receivePage$en.internal(_root);
|
late final Translations$receivePage$en receivePage = Translations$receivePage$en.internal(_root);
|
||||||
late final Translations$receiveOptionsPage$en receiveOptionsPage = Translations$receiveOptionsPage$en.internal(_root);
|
late final Translations$receiveOptionsPage$en receiveOptionsPage = Translations$receiveOptionsPage$en.internal(_root);
|
||||||
late final Translations$sendPage$en sendPage = Translations$sendPage$en.internal(_root);
|
late final Translations$sendPage$en sendPage = Translations$sendPage$en.internal(_root);
|
||||||
@@ -388,6 +390,45 @@ class Translations$selectedFilesPage$en {
|
|||||||
String get deleteAll => 'Delete all';
|
String get deleteAll => 'Delete all';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: deviceDetailsPage
|
||||||
|
class Translations$deviceDetailsPage$en {
|
||||||
|
Translations$deviceDetailsPage$en.internal(this._root);
|
||||||
|
|
||||||
|
final Translations _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
|
||||||
|
/// en: 'Device Details'
|
||||||
|
String get title => 'Device Details';
|
||||||
|
|
||||||
|
/// en: 'Favorite'
|
||||||
|
String get favorite => 'Favorite';
|
||||||
|
|
||||||
|
/// en: 'Verify'
|
||||||
|
String get verify => 'Verify';
|
||||||
|
|
||||||
|
late final Translations$deviceDetailsPage$info$en info = Translations$deviceDetailsPage$info$en.internal(_root);
|
||||||
|
late final Translations$deviceDetailsPage$logs$en logs = Translations$deviceDetailsPage$logs$en.internal(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path: verifyPage
|
||||||
|
class Translations$verifyPage$en {
|
||||||
|
Translations$verifyPage$en.internal(this._root);
|
||||||
|
|
||||||
|
final Translations _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
|
||||||
|
/// en: 'Verify'
|
||||||
|
String get title => 'Verify';
|
||||||
|
|
||||||
|
/// en: 'Icons'
|
||||||
|
String get icons => 'Icons';
|
||||||
|
|
||||||
|
/// en: 'Raw'
|
||||||
|
String get raw => 'Raw';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: receivePage
|
// Path: receivePage
|
||||||
class Translations$receivePage$en {
|
class Translations$receivePage$en {
|
||||||
Translations$receivePage$en.internal(this._root);
|
Translations$receivePage$en.internal(this._root);
|
||||||
@@ -1268,6 +1309,42 @@ class Translations$receiveHistoryPage$entryActions$en {
|
|||||||
String get deleteFromHistory => 'Delete from history';
|
String get deleteFromHistory => 'Delete from history';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: deviceDetailsPage.info
|
||||||
|
class Translations$deviceDetailsPage$info$en {
|
||||||
|
Translations$deviceDetailsPage$info$en.internal(this._root);
|
||||||
|
|
||||||
|
final Translations _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
|
||||||
|
/// en: 'Name'
|
||||||
|
String get name => 'Name';
|
||||||
|
|
||||||
|
/// en: 'Address'
|
||||||
|
String get address => 'Address';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path: deviceDetailsPage.logs
|
||||||
|
class Translations$deviceDetailsPage$logs$en {
|
||||||
|
Translations$deviceDetailsPage$logs$en.internal(this._root);
|
||||||
|
|
||||||
|
final Translations _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
|
||||||
|
/// en: 'Logs'
|
||||||
|
String get title => 'Logs';
|
||||||
|
|
||||||
|
/// en: 'No logs available.'
|
||||||
|
String get empty => 'No logs available.';
|
||||||
|
|
||||||
|
/// en: 'Discovered via {protocol} ({host})'
|
||||||
|
String discovered({required Object protocol, required Object host}) => 'Discovered via ${protocol} (${host})';
|
||||||
|
|
||||||
|
/// en: 'Updated via {protocol} ({host})'
|
||||||
|
String updated({required Object protocol, required Object host}) => 'Updated via ${protocol} (${host})';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: progressPage.total
|
// Path: progressPage.total
|
||||||
class Translations$progressPage$total$en {
|
class Translations$progressPage$total$en {
|
||||||
Translations$progressPage$total$en.internal(this._root);
|
Translations$progressPage$total$en.internal(this._root);
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:localsend_app/gen/strings.g.dart';
|
||||||
|
import 'package:localsend_app/model/persistence/favorite_device.dart';
|
||||||
|
import 'package:localsend_app/pages/verify_page.dart';
|
||||||
|
import 'package:localsend_app/provider/favorites_provider.dart';
|
||||||
|
import 'package:localsend_app/util/favorites.dart';
|
||||||
|
import 'package:localsend_app/widget/big_button.dart';
|
||||||
|
import 'package:localsend_app/widget/dialogs/favorite_delete_dialog.dart';
|
||||||
|
import 'package:localsend_app/widget/dialogs/favorite_edit_dialog.dart';
|
||||||
|
import 'package:localsend_app/widget/responsive_list_view.dart';
|
||||||
|
import 'package:localsend_isolates/isolate.dart';
|
||||||
|
import 'package:localsend_isolates/model/device.dart';
|
||||||
|
import 'package:refena_flutter/refena_flutter.dart';
|
||||||
|
import 'package:routerino/routerino.dart';
|
||||||
|
|
||||||
|
final _timeFormat = DateFormat.jm(LocaleSettings.currentLocale.languageTag);
|
||||||
|
|
||||||
|
/// Shows the general information of a discovered device and
|
||||||
|
/// the log of its retained discovery confirmations.
|
||||||
|
class DeviceDetailsPage extends StatefulWidget {
|
||||||
|
final Device device;
|
||||||
|
|
||||||
|
const DeviceDetailsPage({required this.device});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeviceDetailsPage> createState() => _DeviceDetailsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeviceDetailsPageState extends State<DeviceDetailsPage> with Refena {
|
||||||
|
List<DeviceLog> _logs = const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
ensureRef((ref) async {
|
||||||
|
final logs = await ref
|
||||||
|
.redux(parentIsolateProvider)
|
||||||
|
.dispatchAsyncTakeResult(IsolateDiscoveryDeviceLogsAction(fingerprint: widget.device.fingerprint));
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _logs = logs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFavorite(FavoriteDevice? favoriteEntry) async {
|
||||||
|
if (favoriteEntry != null) {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => FavoriteDeleteDialog(favoriteEntry),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
await ref.redux(favoritesProvider).dispatchAsync(RemoveFavoriteAction(deviceFingerprint: widget.device.fingerprint));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => FavoriteEditDialog(prefilledDevice: widget.device),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final device = widget.device;
|
||||||
|
final favoriteEntry = ref.watch(favoritesProvider).findDevice(device);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(t.deviceDetailsPage.title),
|
||||||
|
),
|
||||||
|
body: ResponsiveListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20),
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
BigButton(
|
||||||
|
icon: favoriteEntry != null ? Icons.favorite : Icons.favorite_border,
|
||||||
|
label: t.deviceDetailsPage.favorite,
|
||||||
|
filled: false,
|
||||||
|
onTap: () async => await _toggleFavorite(favoriteEntry),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
BigButton(
|
||||||
|
icon: Icons.verified_user,
|
||||||
|
label: t.deviceDetailsPage.verify,
|
||||||
|
filled: false,
|
||||||
|
onTap: () async => await context.push(() => VerifyPage(device: device)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
Table(
|
||||||
|
columnWidths: const {
|
||||||
|
0: IntrinsicColumnWidth(),
|
||||||
|
1: FlexColumnWidth(),
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
for (final entry in {
|
||||||
|
t.deviceDetailsPage.info.name: device.alias,
|
||||||
|
if (device.ip != null) t.deviceDetailsPage.info.address: '${device.ip}:${device.port}',
|
||||||
|
}.entries)
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 15, bottom: 5),
|
||||||
|
child: Text(entry.key, style: const TextStyle(color: Colors.grey)),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 5),
|
||||||
|
child: SelectableText(entry.value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(t.deviceDetailsPage.logs.title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (_logs.isEmpty)
|
||||||
|
Text(t.deviceDetailsPage.logs.empty, style: const TextStyle(color: Colors.grey))
|
||||||
|
else
|
||||||
|
..._logs.map(
|
||||||
|
(log) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 5),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('[${_timeFormat.format(log.timestamp)}]'),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(log.description),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension on DeviceLog {
|
||||||
|
String get description {
|
||||||
|
final protocol = channel.https ? 'HTTPS' : 'HTTP';
|
||||||
|
return switch (kind) {
|
||||||
|
DeviceLogKind.discovered => t.deviceDetailsPage.logs.discovered(protocol: protocol, host: channel.host),
|
||||||
|
DeviceLogKind.updated => t.deviceDetailsPage.logs.updated(protocol: protocol, host: channel.host),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:localsend_app/config/theme.dart';
|
import 'package:localsend_app/config/theme.dart';
|
||||||
import 'package:localsend_app/gen/strings.g.dart';
|
import 'package:localsend_app/gen/strings.g.dart';
|
||||||
import 'package:localsend_app/model/send_mode.dart';
|
import 'package:localsend_app/model/send_mode.dart';
|
||||||
|
import 'package:localsend_app/pages/device_details_page.dart';
|
||||||
import 'package:localsend_app/pages/selected_files_page.dart';
|
import 'package:localsend_app/pages/selected_files_page.dart';
|
||||||
import 'package:localsend_app/pages/tabs/send_tab_vm.dart';
|
import 'package:localsend_app/pages/tabs/send_tab_vm.dart';
|
||||||
import 'package:localsend_app/pages/troubleshoot_page.dart';
|
import 'package:localsend_app/pages/troubleshoot_page.dart';
|
||||||
@@ -215,15 +216,13 @@ class SendTab extends StatelessWidget {
|
|||||||
child: vm.sendMode == SendMode.multiple
|
child: vm.sendMode == SendMode.multiple
|
||||||
? _MultiSendDeviceListTile(
|
? _MultiSendDeviceListTile(
|
||||||
device: device,
|
device: device,
|
||||||
isFavorite: favoriteEntry != null,
|
|
||||||
nameOverride: favoriteEntry?.alias,
|
nameOverride: favoriteEntry?.alias,
|
||||||
vm: vm,
|
vm: vm,
|
||||||
)
|
)
|
||||||
: DeviceListTile(
|
: DeviceListTile(
|
||||||
device: device,
|
device: device,
|
||||||
isFavorite: favoriteEntry != null,
|
|
||||||
nameOverride: favoriteEntry?.alias,
|
nameOverride: favoriteEntry?.alias,
|
||||||
onFavoriteTap: () async => await vm.onToggleFavorite(context, device),
|
onDetailsTap: () async => await context.push(() => DeviceDetailsPage(device: device)),
|
||||||
onTap: () async => await vm.onTapDevice(context, device),
|
onTap: () async => await vm.onTapDevice(context, device),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -514,13 +513,11 @@ class _SendModeButton extends StatelessWidget {
|
|||||||
/// An advanced list tile which shows the progress of the file transfer.
|
/// An advanced list tile which shows the progress of the file transfer.
|
||||||
class _MultiSendDeviceListTile extends StatelessWidget {
|
class _MultiSendDeviceListTile extends StatelessWidget {
|
||||||
final Device device;
|
final Device device;
|
||||||
final bool isFavorite;
|
|
||||||
final String? nameOverride;
|
final String? nameOverride;
|
||||||
final SendTabVm vm;
|
final SendTabVm vm;
|
||||||
|
|
||||||
const _MultiSendDeviceListTile({
|
const _MultiSendDeviceListTile({
|
||||||
required this.device,
|
required this.device,
|
||||||
required this.isFavorite,
|
|
||||||
required this.nameOverride,
|
required this.nameOverride,
|
||||||
required this.vm,
|
required this.vm,
|
||||||
});
|
});
|
||||||
@@ -551,9 +548,8 @@ class _MultiSendDeviceListTile extends StatelessWidget {
|
|||||||
device: device,
|
device: device,
|
||||||
info: info,
|
info: info,
|
||||||
progress: progress,
|
progress: progress,
|
||||||
isFavorite: isFavorite,
|
|
||||||
nameOverride: nameOverride,
|
nameOverride: nameOverride,
|
||||||
onFavoriteTap: device.ip == null ? null : () async => await vm.onToggleFavorite(context, device),
|
onDetailsTap: () async => await context.push(() => DeviceDetailsPage(device: device)),
|
||||||
onTap: () async => await vm.onTapDeviceMultiSend(context, device),
|
onTap: () async => await vm.onTapDeviceMultiSend(context, device),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,8 @@ import 'package:localsend_app/provider/network/scan_facade.dart';
|
|||||||
import 'package:localsend_app/provider/network/send_provider.dart';
|
import 'package:localsend_app/provider/network/send_provider.dart';
|
||||||
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
|
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
|
||||||
import 'package:localsend_app/provider/settings_provider.dart';
|
import 'package:localsend_app/provider/settings_provider.dart';
|
||||||
import 'package:localsend_app/util/favorites.dart';
|
|
||||||
import 'package:localsend_app/widget/dialogs/address_input_dialog.dart';
|
import 'package:localsend_app/widget/dialogs/address_input_dialog.dart';
|
||||||
import 'package:localsend_app/widget/dialogs/favorite_delete_dialog.dart';
|
|
||||||
import 'package:localsend_app/widget/dialogs/favorite_dialog.dart';
|
import 'package:localsend_app/widget/dialogs/favorite_dialog.dart';
|
||||||
import 'package:localsend_app/widget/dialogs/favorite_edit_dialog.dart';
|
|
||||||
import 'package:localsend_app/widget/dialogs/no_files_dialog.dart';
|
import 'package:localsend_app/widget/dialogs/no_files_dialog.dart';
|
||||||
import 'package:localsend_isolates/model/device.dart';
|
import 'package:localsend_isolates/model/device.dart';
|
||||||
import 'package:localsend_isolates/model/session_status.dart';
|
import 'package:localsend_isolates/model/session_status.dart';
|
||||||
@@ -33,7 +30,6 @@ class SendTabVm {
|
|||||||
final Future<void> Function(BuildContext context) onTapAddress;
|
final Future<void> Function(BuildContext context) onTapAddress;
|
||||||
final Future<void> Function(BuildContext context) onTapFavorite;
|
final Future<void> Function(BuildContext context) onTapFavorite;
|
||||||
final Future<void> Function(BuildContext context, SendMode mode) onTapSendMode;
|
final Future<void> Function(BuildContext context, SendMode mode) onTapSendMode;
|
||||||
final Future<void> Function(BuildContext context, Device device) onToggleFavorite;
|
|
||||||
final Future<void> Function(BuildContext context, Device device) onTapDevice;
|
final Future<void> Function(BuildContext context, Device device) onTapDevice;
|
||||||
final Future<void> Function(BuildContext context, Device device) onTapDeviceMultiSend;
|
final Future<void> Function(BuildContext context, Device device) onTapDeviceMultiSend;
|
||||||
|
|
||||||
@@ -46,7 +42,6 @@ class SendTabVm {
|
|||||||
required this.onTapAddress,
|
required this.onTapAddress,
|
||||||
required this.onTapFavorite,
|
required this.onTapFavorite,
|
||||||
required this.onTapSendMode,
|
required this.onTapSendMode,
|
||||||
required this.onToggleFavorite,
|
|
||||||
required this.onTapDevice,
|
required this.onTapDevice,
|
||||||
required this.onTapDeviceMultiSend,
|
required this.onTapDeviceMultiSend,
|
||||||
});
|
});
|
||||||
@@ -122,23 +117,6 @@ final sendTabVmProvider = ViewProvider((ref) {
|
|||||||
ref.notifier(sendProvider).clearAllSessions();
|
ref.notifier(sendProvider).clearAllSessions();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToggleFavorite: (context, device) async {
|
|
||||||
final favoriteDevice = favoriteDevices.findDevice(device);
|
|
||||||
if (favoriteDevice != null) {
|
|
||||||
final result = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => FavoriteDeleteDialog(favoriteDevice),
|
|
||||||
);
|
|
||||||
if (result == true) {
|
|
||||||
await ref.redux(favoritesProvider).dispatchAsync(RemoveFavoriteAction(deviceFingerprint: device.fingerprint));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => FavoriteEditDialog(prefilledDevice: device),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTapDevice: (context, device) async {
|
onTapDevice: (context, device) async {
|
||||||
if (selectedFiles.isEmpty) {
|
if (selectedFiles.isEmpty) {
|
||||||
await context.pushBottomSheet(() => const NoFilesDialog());
|
await context.pushBottomSheet(() => const NoFilesDialog());
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:localsend_app/gen/strings.g.dart';
|
||||||
|
import 'package:localsend_app/provider/security_provider.dart';
|
||||||
|
import 'package:localsend_app/util/fingerprint_alphabet.dart';
|
||||||
|
import 'package:localsend_app/widget/responsive_list_view.dart';
|
||||||
|
import 'package:localsend_isolates/model/device.dart';
|
||||||
|
import 'package:refena_flutter/refena_flutter.dart';
|
||||||
|
|
||||||
|
enum _VerifyMode {
|
||||||
|
icons,
|
||||||
|
raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies the identity of a discovered device by comparing the fingerprints
|
||||||
|
/// of both sides.
|
||||||
|
class VerifyPage extends StatefulWidget {
|
||||||
|
final Device device;
|
||||||
|
|
||||||
|
const VerifyPage({required this.device});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VerifyPage> createState() => _VerifyPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VerifyPageState extends State<VerifyPage> {
|
||||||
|
_VerifyMode _mode = _VerifyMode.icons;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final myFingerprint = context.ref.watch(securityProvider.select((s) => s.certificateHash));
|
||||||
|
final fingerprints = [myFingerprint, widget.device.fingerprint]..sort();
|
||||||
|
final combined = fingerprints.join();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(t.verifyPage.title),
|
||||||
|
),
|
||||||
|
body: ResponsiveListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20),
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: SegmentedButton<_VerifyMode>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment(value: _VerifyMode.icons, label: Text(t.verifyPage.icons)),
|
||||||
|
ButtonSegment(value: _VerifyMode.raw, label: Text(t.verifyPage.raw)),
|
||||||
|
],
|
||||||
|
selected: {_mode},
|
||||||
|
onSelectionChanged: (selection) => setState(() => _mode = selection.first),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
switch (_mode) {
|
||||||
|
_VerifyMode.icons => Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 240),
|
||||||
|
child: GridView.count(
|
||||||
|
crossAxisCount: 4,
|
||||||
|
mainAxisSpacing: 10,
|
||||||
|
crossAxisSpacing: 10,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
for (final icon in fingerprintToIcons(combined)) Icon(icon, size: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_VerifyMode.raw => SelectableText(combined),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// The icon alphabet used to visualize fingerprints.
|
||||||
|
const iconAlphabet = <IconData>[
|
||||||
|
Icons.ac_unit,
|
||||||
|
Icons.access_alarm,
|
||||||
|
Icons.access_time,
|
||||||
|
Icons.accessibility,
|
||||||
|
Icons.accessibility_new,
|
||||||
|
Icons.account_balance,
|
||||||
|
Icons.account_balance_wallet,
|
||||||
|
Icons.account_box,
|
||||||
|
Icons.account_circle,
|
||||||
|
Icons.adb,
|
||||||
|
Icons.add,
|
||||||
|
Icons.add_a_photo,
|
||||||
|
Icons.add_alarm,
|
||||||
|
Icons.add_alert,
|
||||||
|
Icons.add_box,
|
||||||
|
Icons.add_circle,
|
||||||
|
Icons.add_circle_outline,
|
||||||
|
Icons.add_comment,
|
||||||
|
Icons.add_location,
|
||||||
|
Icons.add_photo_alternate,
|
||||||
|
Icons.add_shopping_cart,
|
||||||
|
Icons.alarm,
|
||||||
|
Icons.alarm_add,
|
||||||
|
Icons.alarm_off,
|
||||||
|
Icons.alarm_on,
|
||||||
|
Icons.album,
|
||||||
|
Icons.all_inbox,
|
||||||
|
Icons.all_inclusive,
|
||||||
|
Icons.analytics,
|
||||||
|
Icons.android,
|
||||||
|
Icons.announcement,
|
||||||
|
Icons.api,
|
||||||
|
Icons.app_blocking,
|
||||||
|
Icons.app_registration,
|
||||||
|
Icons.app_settings_alt,
|
||||||
|
Icons.apple,
|
||||||
|
Icons.apps,
|
||||||
|
Icons.archive,
|
||||||
|
Icons.arrow_back,
|
||||||
|
Icons.arrow_back_ios,
|
||||||
|
Icons.arrow_downward,
|
||||||
|
Icons.arrow_drop_down,
|
||||||
|
Icons.arrow_drop_up,
|
||||||
|
Icons.arrow_forward,
|
||||||
|
Icons.arrow_forward_ios,
|
||||||
|
Icons.arrow_left,
|
||||||
|
Icons.arrow_right,
|
||||||
|
Icons.arrow_upward,
|
||||||
|
Icons.article,
|
||||||
|
Icons.aspect_ratio,
|
||||||
|
Icons.assessment,
|
||||||
|
Icons.assignment,
|
||||||
|
Icons.assignment_ind,
|
||||||
|
Icons.assignment_late,
|
||||||
|
Icons.assignment_return,
|
||||||
|
Icons.assignment_turned_in,
|
||||||
|
Icons.assistant,
|
||||||
|
Icons.attach_email,
|
||||||
|
Icons.attach_file,
|
||||||
|
Icons.attach_money,
|
||||||
|
Icons.attachment,
|
||||||
|
Icons.attribution,
|
||||||
|
Icons.audiotrack,
|
||||||
|
Icons.auto_awesome,
|
||||||
|
Icons.auto_delete,
|
||||||
|
Icons.auto_fix_high,
|
||||||
|
Icons.autorenew,
|
||||||
|
Icons.backspace,
|
||||||
|
Icons.backup,
|
||||||
|
Icons.badge,
|
||||||
|
Icons.bar_chart,
|
||||||
|
Icons.batch_prediction,
|
||||||
|
Icons.battery_charging_full,
|
||||||
|
Icons.battery_full,
|
||||||
|
Icons.battery_std,
|
||||||
|
Icons.beach_access,
|
||||||
|
Icons.beenhere,
|
||||||
|
Icons.block,
|
||||||
|
Icons.bluetooth,
|
||||||
|
Icons.bluetooth_connected,
|
||||||
|
Icons.bluetooth_disabled,
|
||||||
|
Icons.bluetooth_searching,
|
||||||
|
Icons.book,
|
||||||
|
Icons.bookmark,
|
||||||
|
Icons.bookmark_border,
|
||||||
|
Icons.bookmarks,
|
||||||
|
Icons.border_all,
|
||||||
|
Icons.border_color,
|
||||||
|
Icons.build,
|
||||||
|
Icons.business,
|
||||||
|
Icons.business_center,
|
||||||
|
Icons.calculate,
|
||||||
|
Icons.calendar_today,
|
||||||
|
Icons.call,
|
||||||
|
Icons.call_end,
|
||||||
|
Icons.call_made,
|
||||||
|
Icons.call_merge,
|
||||||
|
Icons.call_missed,
|
||||||
|
Icons.call_received,
|
||||||
|
Icons.call_split,
|
||||||
|
Icons.camera,
|
||||||
|
Icons.camera_alt,
|
||||||
|
Icons.camera_enhance,
|
||||||
|
Icons.camera_front,
|
||||||
|
Icons.camera_rear,
|
||||||
|
Icons.cancel,
|
||||||
|
Icons.card_giftcard,
|
||||||
|
Icons.card_membership,
|
||||||
|
Icons.card_travel,
|
||||||
|
Icons.category,
|
||||||
|
Icons.chat,
|
||||||
|
Icons.chat_bubble,
|
||||||
|
Icons.chat_bubble_outline,
|
||||||
|
Icons.check,
|
||||||
|
Icons.check_box,
|
||||||
|
Icons.check_box_outline_blank,
|
||||||
|
Icons.check_circle,
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
Icons.chevron_left,
|
||||||
|
Icons.chevron_right,
|
||||||
|
Icons.clear,
|
||||||
|
Icons.close,
|
||||||
|
Icons.cloud,
|
||||||
|
Icons.cloud_circle,
|
||||||
|
Icons.cloud_download,
|
||||||
|
Icons.cloud_off,
|
||||||
|
Icons.cloud_queue,
|
||||||
|
Icons.cloud_upload,
|
||||||
|
Icons.code,
|
||||||
|
Icons.comment,
|
||||||
|
Icons.computer,
|
||||||
|
Icons.contact_mail,
|
||||||
|
Icons.contact_phone,
|
||||||
|
Icons.contact_support,
|
||||||
|
Icons.contacts,
|
||||||
|
Icons.content_copy,
|
||||||
|
Icons.content_cut,
|
||||||
|
Icons.content_paste,
|
||||||
|
Icons.credit_card,
|
||||||
|
Icons.crop,
|
||||||
|
Icons.dashboard,
|
||||||
|
Icons.data_usage,
|
||||||
|
Icons.delete,
|
||||||
|
Icons.delete_forever,
|
||||||
|
Icons.delete_outline,
|
||||||
|
Icons.description,
|
||||||
|
Icons.desktop_mac,
|
||||||
|
Icons.desktop_windows,
|
||||||
|
Icons.details,
|
||||||
|
Icons.developer_board,
|
||||||
|
Icons.developer_mode,
|
||||||
|
Icons.device_hub,
|
||||||
|
Icons.devices,
|
||||||
|
Icons.dialpad,
|
||||||
|
Icons.directions,
|
||||||
|
Icons.directions_bike,
|
||||||
|
Icons.directions_bus,
|
||||||
|
Icons.directions_car,
|
||||||
|
Icons.directions_run,
|
||||||
|
Icons.directions_walk,
|
||||||
|
Icons.disc_full,
|
||||||
|
Icons.dns,
|
||||||
|
Icons.done,
|
||||||
|
Icons.done_all,
|
||||||
|
Icons.done_outline,
|
||||||
|
Icons.drafts,
|
||||||
|
Icons.edit,
|
||||||
|
Icons.edit_location,
|
||||||
|
Icons.email,
|
||||||
|
Icons.error,
|
||||||
|
Icons.error_outline,
|
||||||
|
Icons.event,
|
||||||
|
Icons.event_available,
|
||||||
|
Icons.event_busy,
|
||||||
|
Icons.event_note,
|
||||||
|
Icons.event_seat,
|
||||||
|
Icons.exit_to_app,
|
||||||
|
Icons.expand_less,
|
||||||
|
Icons.expand_more,
|
||||||
|
Icons.explicit,
|
||||||
|
Icons.explore,
|
||||||
|
Icons.extension,
|
||||||
|
Icons.face,
|
||||||
|
Icons.facebook,
|
||||||
|
Icons.fast_forward,
|
||||||
|
Icons.fast_rewind,
|
||||||
|
Icons.favorite,
|
||||||
|
Icons.favorite_border,
|
||||||
|
Icons.featured_play_list,
|
||||||
|
Icons.featured_video,
|
||||||
|
Icons.feedback,
|
||||||
|
Icons.file_download,
|
||||||
|
Icons.file_upload,
|
||||||
|
Icons.filter,
|
||||||
|
Icons.filter_alt,
|
||||||
|
Icons.filter_list,
|
||||||
|
Icons.find_in_page,
|
||||||
|
Icons.fingerprint,
|
||||||
|
Icons.flag,
|
||||||
|
Icons.flash_auto,
|
||||||
|
Icons.flash_off,
|
||||||
|
Icons.flash_on,
|
||||||
|
Icons.flight,
|
||||||
|
Icons.flight_land,
|
||||||
|
Icons.flight_takeoff,
|
||||||
|
Icons.folder,
|
||||||
|
Icons.folder_open,
|
||||||
|
Icons.folder_shared,
|
||||||
|
Icons.forum,
|
||||||
|
Icons.forward,
|
||||||
|
Icons.fullscreen,
|
||||||
|
Icons.fullscreen_exit,
|
||||||
|
Icons.games,
|
||||||
|
Icons.gavel,
|
||||||
|
Icons.gesture,
|
||||||
|
Icons.get_app,
|
||||||
|
Icons.gif,
|
||||||
|
Icons.grade,
|
||||||
|
Icons.group,
|
||||||
|
Icons.group_add,
|
||||||
|
Icons.group_work,
|
||||||
|
Icons.hd,
|
||||||
|
Icons.headset,
|
||||||
|
Icons.headset_mic,
|
||||||
|
Icons.healing,
|
||||||
|
Icons.hearing,
|
||||||
|
Icons.help,
|
||||||
|
Icons.help_outline,
|
||||||
|
Icons.history,
|
||||||
|
Icons.home,
|
||||||
|
Icons.hotel,
|
||||||
|
Icons.hourglass_empty,
|
||||||
|
Icons.hourglass_full,
|
||||||
|
Icons.http,
|
||||||
|
Icons.https,
|
||||||
|
Icons.image,
|
||||||
|
Icons.image_aspect_ratio,
|
||||||
|
Icons.import_contacts,
|
||||||
|
Icons.import_export,
|
||||||
|
Icons.inbox,
|
||||||
|
Icons.info,
|
||||||
|
Icons.info_outline,
|
||||||
|
Icons.input,
|
||||||
|
Icons.key,
|
||||||
|
Icons.keyboard,
|
||||||
|
Icons.label,
|
||||||
|
Icons.landscape,
|
||||||
|
Icons.language,
|
||||||
|
Icons.laptop,
|
||||||
|
Icons.launch,
|
||||||
|
Icons.layers,
|
||||||
|
Icons.lightbulb,
|
||||||
|
Icons.link,
|
||||||
|
Icons.list,
|
||||||
|
Icons.local_activity,
|
||||||
|
Icons.local_airport,
|
||||||
|
Icons.local_atm,
|
||||||
|
Icons.local_bar,
|
||||||
|
Icons.local_cafe,
|
||||||
|
Icons.local_dining,
|
||||||
|
Icons.local_grocery_store,
|
||||||
|
Icons.local_hospital,
|
||||||
|
Icons.local_hotel,
|
||||||
|
Icons.local_offer,
|
||||||
|
Icons.local_phone,
|
||||||
|
Icons.local_pizza,
|
||||||
|
Icons.local_post_office,
|
||||||
|
Icons.local_see,
|
||||||
|
Icons.local_shipping,
|
||||||
|
Icons.local_taxi,
|
||||||
|
Icons.lock,
|
||||||
|
Icons.lock_open,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Hashes the [combined] fingerprints and renders the first 128 bits as 16 icons of [iconAlphabet].
|
||||||
|
List<IconData> fingerprintToIcons(String combined) {
|
||||||
|
final digest = sha256.convert(utf8.encode(combined));
|
||||||
|
final hex = digest.bytes.sublist(0, 16).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
return convertFingerprintToAlphabet(fingerprint: hex, alphabet: iconAlphabet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a hex [fingerprint] to a sequence of items of [alphabet] using base conversion.
|
||||||
|
/// The result has a fixed length depending only on the input and alphabet sizes.
|
||||||
|
List<T> convertFingerprintToAlphabet<T>({
|
||||||
|
required String fingerprint,
|
||||||
|
required List<T> alphabet,
|
||||||
|
}) {
|
||||||
|
final base = BigInt.from(alphabet.length);
|
||||||
|
final length = (fingerprint.length * 4 * log(2) / log(alphabet.length)).ceil();
|
||||||
|
var value = BigInt.parse(fingerprint, radix: 16);
|
||||||
|
final result = List<T>.filled(length, alphabet[0]);
|
||||||
|
for (var i = length - 1; i >= 0; i--) {
|
||||||
|
result[i] = alphabet[(value % base).toInt()];
|
||||||
|
value ~/= base;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import 'package:localsend_isolates/model/device.dart';
|
|||||||
|
|
||||||
class DeviceListTile extends StatelessWidget {
|
class DeviceListTile extends StatelessWidget {
|
||||||
final Device device;
|
final Device device;
|
||||||
final bool isFavorite;
|
|
||||||
|
|
||||||
/// If not null, this name is used instead of [Device.alias].
|
/// If not null, this name is used instead of [Device.alias].
|
||||||
/// This is the case when the device is marked as favorite.
|
/// This is the case when the device is marked as favorite.
|
||||||
@@ -16,16 +15,15 @@ class DeviceListTile extends StatelessWidget {
|
|||||||
final String? info;
|
final String? info;
|
||||||
final double? progress;
|
final double? progress;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onFavoriteTap;
|
final VoidCallback? onDetailsTap;
|
||||||
|
|
||||||
const DeviceListTile({
|
const DeviceListTile({
|
||||||
required this.device,
|
required this.device,
|
||||||
this.isFavorite = false,
|
|
||||||
this.nameOverride,
|
this.nameOverride,
|
||||||
this.info,
|
this.info,
|
||||||
this.progress,
|
this.progress,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onFavoriteTap,
|
this.onDetailsTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -34,10 +32,10 @@ class DeviceListTile extends StatelessWidget {
|
|||||||
return CustomListTile(
|
return CustomListTile(
|
||||||
icon: Icon(device.deviceType.icon, size: 46),
|
icon: Icon(device.deviceType.icon, size: 46),
|
||||||
title: Text(nameOverride ?? device.alias, style: const TextStyle(fontSize: 20)),
|
title: Text(nameOverride ?? device.alias, style: const TextStyle(fontSize: 20)),
|
||||||
trailing: onFavoriteTap != null
|
trailing: onDetailsTap != null
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border),
|
icon: const Icon(Icons.info_outline),
|
||||||
onPressed: onFavoriteTap,
|
onPressed: onDetailsTap,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
subTitle: Wrap(
|
subTitle: Wrap(
|
||||||
|
|||||||
+1
-1
@@ -242,7 +242,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+4"
|
version: "0.3.5+4"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
collection: ^1.17.2 # allow newer versions, so it can compile with newer Flutter versions
|
collection: ^1.17.2 # allow newer versions, so it can compile with newer Flutter versions
|
||||||
connectivity_plus: 7.2.0
|
connectivity_plus: 7.2.0
|
||||||
|
crypto: 3.0.7
|
||||||
dart_mappable: 4.8.0
|
dart_mappable: 4.8.0
|
||||||
desktop_drop: 0.7.1
|
desktop_drop: 0.7.1
|
||||||
device_apps: 2.2.0
|
device_apps: 2.2.0
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:localsend_app/util/fingerprint_alphabet.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('iconAlphabet', () {
|
||||||
|
test('every icon is unique', () {
|
||||||
|
expect(iconAlphabet.toSet().length, iconAlphabet.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fingerprintToIcons', () {
|
||||||
|
test('renders 16 icons', () {
|
||||||
|
expect(fingerprintToIcons('A1B2C3' * 20).length, 16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is deterministic and input-sensitive', () {
|
||||||
|
expect(fingerprintToIcons('ABC'), fingerprintToIcons('ABC'));
|
||||||
|
expect(fingerprintToIcons('ABC'), isNot(fingerprintToIcons('ABD')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('convertFingerprintToAlphabet', () {
|
||||||
|
test('converts hex to a hex alphabet', () {
|
||||||
|
final alphabet = '0123456789ABCDEF'.split('');
|
||||||
|
expect(
|
||||||
|
convertFingerprintToAlphabet(fingerprint: 'A1B2C3', alphabet: alphabet),
|
||||||
|
['A', '1', 'B', '2', 'C', '3'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves leading zeros', () {
|
||||||
|
final alphabet = '0123456789ABCDEF'.split('');
|
||||||
|
expect(
|
||||||
|
convertFingerprintToAlphabet(fingerprint: '00FF', alphabet: alphabet),
|
||||||
|
['0', '0', 'F', 'F'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts to a smaller alphabet', () {
|
||||||
|
expect(
|
||||||
|
convertFingerprintToAlphabet(fingerprint: 'F', alphabet: [0, 1]),
|
||||||
|
[1, 1, 1, 1],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has a fixed length for a non-power-of-two alphabet', () {
|
||||||
|
final alphabet = List.generate(10, (i) => i);
|
||||||
|
// 64 hex chars = 256 bits, ceil(256 / log2(10)) = 78
|
||||||
|
expect(
|
||||||
|
convertFingerprintToAlphabet(fingerprint: 'F' * 64, alphabet: alphabet).length,
|
||||||
|
78,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
convertFingerprintToAlphabet(fingerprint: '0' * 64, alphabet: alphabet).length,
|
||||||
|
78,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips for any alphabet size', () {
|
||||||
|
const fingerprint = 'A1B2C3D4E5F60718293A4B5C6D7E8F90';
|
||||||
|
for (var size = 2; size <= 100; size++) {
|
||||||
|
final alphabet = List.generate(size, (i) => i);
|
||||||
|
final digits = convertFingerprintToAlphabet(fingerprint: fingerprint, alphabet: alphabet);
|
||||||
|
final value = digits.fold(BigInt.zero, (acc, d) => acc * BigInt.from(size) + BigInt.from(d));
|
||||||
|
expect(value, BigInt.parse(fingerprint, radix: 16));
|
||||||
|
expect(digits.length, (fingerprint.length * 4 * log(2) / log(size)).ceil());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -41,6 +41,29 @@ class SignalingChannel extends DeviceChannel with SignalingChannelMappable {
|
|||||||
const SignalingChannel({required this.signalingServer});
|
const SignalingChannel({required this.signalingServer});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a [DeviceLog] discovered the device or re-confirmed it.
|
||||||
|
enum DeviceLogKind {
|
||||||
|
discovered,
|
||||||
|
updated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One retained confirmation of a stored device, for the device details UI.
|
||||||
|
class DeviceLog {
|
||||||
|
/// When the confirmation happened.
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
final DeviceLogKind kind;
|
||||||
|
|
||||||
|
/// The channel the confirmation happened on.
|
||||||
|
final HttpChannel channel;
|
||||||
|
|
||||||
|
const DeviceLog({
|
||||||
|
required this.timestamp,
|
||||||
|
required this.kind,
|
||||||
|
required this.channel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
enum TransmissionMethod {
|
enum TransmissionMethod {
|
||||||
http('HTTP'),
|
http('HTTP'),
|
||||||
webrtc('WebRTC')
|
webrtc('WebRTC')
|
||||||
|
|||||||
@@ -6,6 +6,29 @@ import 'package:typed_isolates/typed_isolates.dart';
|
|||||||
|
|
||||||
sealed class DiscoveryTask {}
|
sealed class DiscoveryTask {}
|
||||||
|
|
||||||
|
/// A result sent from the discovery isolate to the main isolate,
|
||||||
|
/// routed to the task that requested it.
|
||||||
|
sealed class DiscoveryResult {}
|
||||||
|
|
||||||
|
/// A confirmed device, emitted on the [DiscoveryListenTask] stream.
|
||||||
|
class DiscoveryDeviceResult implements DiscoveryResult {
|
||||||
|
final Device device;
|
||||||
|
|
||||||
|
DiscoveryDeviceResult({
|
||||||
|
required this.device,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The retained confirmations of one stored device, answering a
|
||||||
|
/// [DiscoveryDeviceLogsTask].
|
||||||
|
class DiscoveryDeviceLogsResult implements DiscoveryResult {
|
||||||
|
final List<DeviceLog> logs;
|
||||||
|
|
||||||
|
DiscoveryDeviceLogsResult({
|
||||||
|
required this.logs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Starts the discovery and streams every confirmed device.
|
/// Starts the discovery and streams every confirmed device.
|
||||||
/// The stream never completes; it survives [DiscoveryRestartTask]s.
|
/// The stream never completes; it survives [DiscoveryRestartTask]s.
|
||||||
class DiscoveryListenTask implements DiscoveryTask {}
|
class DiscoveryListenTask implements DiscoveryTask {}
|
||||||
@@ -56,9 +79,20 @@ class DiscoveryAddDeviceTask implements DiscoveryTask {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches the retained confirmations of a stored device, oldest first.
|
||||||
|
/// Answered with one [DiscoveryDeviceLogsResult]; the logs are empty when
|
||||||
|
/// the fingerprint is unknown.
|
||||||
|
class DiscoveryDeviceLogsTask implements DiscoveryTask {
|
||||||
|
final String fingerprint;
|
||||||
|
|
||||||
|
DiscoveryDeviceLogsTask({
|
||||||
|
required this.fingerprint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setupDiscoveryIsolate(
|
Future<void> setupDiscoveryIsolate(
|
||||||
Stream<SendToIsolateData<IsolateTask<DiscoveryTask>>> receiveFromMain,
|
Stream<SendToIsolateData<IsolateTask<DiscoveryTask>>> receiveFromMain,
|
||||||
void Function(IsolateTaskStreamResult<Device>) sendToMain,
|
void Function(IsolateTaskStreamResult<DiscoveryResult>) sendToMain,
|
||||||
InitialData initialData,
|
InitialData initialData,
|
||||||
) async {
|
) async {
|
||||||
await setupChildIsolateHelper(
|
await setupChildIsolateHelper(
|
||||||
@@ -73,7 +107,7 @@ Future<void> setupDiscoveryIsolate(
|
|||||||
sendToMain(
|
sendToMain(
|
||||||
IsolateTaskStreamResult.event(
|
IsolateTaskStreamResult.event(
|
||||||
id: task.id,
|
id: task.id,
|
||||||
data: device,
|
data: DiscoveryDeviceResult(device: device),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,6 +138,15 @@ Future<void> setupDiscoveryIsolate(
|
|||||||
case DiscoveryAddDeviceTask data:
|
case DiscoveryAddDeviceTask data:
|
||||||
await ref.read(discoveryProvider).addDevice(data.device);
|
await ref.read(discoveryProvider).addDevice(data.device);
|
||||||
break;
|
break;
|
||||||
|
case DiscoveryDeviceLogsTask data:
|
||||||
|
final logs = await ref.read(discoveryProvider).deviceLogs(data.fingerprint);
|
||||||
|
sendToMain(
|
||||||
|
IsolateTaskStreamResult.event(
|
||||||
|
id: task.id,
|
||||||
|
data: DiscoveryDeviceLogsResult(logs: logs),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
sendToMain(
|
sendToMain(
|
||||||
IsolateTaskStreamResult.done(
|
IsolateTaskStreamResult.done(
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ class IsolateDiscoveryListenAction extends ReduxActionWithResult<IsolateControll
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
state,
|
state,
|
||||||
connection.sendWrappedTaskAndListenStream(
|
connection
|
||||||
|
.sendWrappedTaskAndListenStream(
|
||||||
task: DiscoveryListenTask(),
|
task: DiscoveryListenTask(),
|
||||||
),
|
)
|
||||||
|
.toDeviceStream(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,13 +58,15 @@ class IsolateDiscoverySubnetScanAction extends ReduxActionWithResult<IsolateCont
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
state,
|
state,
|
||||||
connection.sendWrappedTaskAndListenStream(
|
connection
|
||||||
|
.sendWrappedTaskAndListenStream(
|
||||||
task: DiscoverySubnetScanTask(
|
task: DiscoverySubnetScanTask(
|
||||||
networkInterface: networkInterface,
|
networkInterface: networkInterface,
|
||||||
port: port,
|
port: port,
|
||||||
https: https,
|
https: https,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
|
.toDeviceStream(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,16 +92,45 @@ class IsolateDiscoveryFavoriteScanAction extends ReduxActionWithResult<IsolateCo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
state,
|
state,
|
||||||
connection.sendWrappedTaskAndListenStream(
|
connection
|
||||||
|
.sendWrappedTaskAndListenStream(
|
||||||
task: DiscoveryFavoriteScanTask(
|
task: DiscoveryFavoriteScanTask(
|
||||||
favorites: favorites,
|
favorites: favorites,
|
||||||
https: https,
|
https: https,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
|
.toDeviceStream(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches the retained confirmations of a stored device, oldest first.
|
||||||
|
/// The logs are empty when the fingerprint is unknown or the discovery is
|
||||||
|
/// not running.
|
||||||
|
class IsolateDiscoveryDeviceLogsAction extends AsyncReduxActionWithResult<IsolateController, ParentIsolateState, List<DeviceLog>> {
|
||||||
|
final String fingerprint;
|
||||||
|
|
||||||
|
IsolateDiscoveryDeviceLogsAction({
|
||||||
|
required this.fingerprint,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<(ParentIsolateState, List<DeviceLog>)> reduce() async {
|
||||||
|
final connection = state.discovery;
|
||||||
|
if (connection == null) {
|
||||||
|
throw StateError('discovery is not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await connection
|
||||||
|
.sendWrappedTaskAndListenStream(
|
||||||
|
task: DiscoveryDeviceLogsTask(fingerprint: fingerprint),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
|
|
||||||
|
return (state, (result as DiscoveryDeviceLogsResult).logs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sends an announcement which makes every other LocalSend device on the
|
/// Sends an announcement which makes every other LocalSend device on the
|
||||||
/// network register with this device's HTTP server.
|
/// network register with this device's HTTP server.
|
||||||
class IsolateDiscoveryAnnouncementAction extends ReduxAction<IsolateController, ParentIsolateState> {
|
class IsolateDiscoveryAnnouncementAction extends ReduxAction<IsolateController, ParentIsolateState> {
|
||||||
@@ -496,6 +529,13 @@ class IsolateHttpServerFailFileDownloadAction extends ReduxAction<IsolateControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension _DeviceStreamExt on Stream<DiscoveryResult> {
|
||||||
|
/// Unwraps the [DiscoveryDeviceResult]s of a device stream.
|
||||||
|
Stream<Device> toDeviceStream() {
|
||||||
|
return map((result) => (result as DiscoveryDeviceResult).device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Adds the [SendToIsolateData] envelope on top of the generic
|
/// Adds the [SendToIsolateData] envelope on top of the generic
|
||||||
/// [IsolateTaskConnector.sendTaskAndListenStream] from `typed_isolates`.
|
/// [IsolateTaskConnector.sendTaskAndListenStream] from `typed_isolates`.
|
||||||
extension _WrappedTaskConnector<R, T> on IsolateConnector<IsolateTaskStreamResult<R>, SendToIsolateData<IsolateTask<T>>> {
|
extension _WrappedTaskConnector<R, T> on IsolateConnector<IsolateTaskStreamResult<R>, SendToIsolateData<IsolateTask<T>>> {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'package:dart_mappable/dart_mappable.dart';
|
import 'package:dart_mappable/dart_mappable.dart';
|
||||||
import 'package:localsend_isolates/model/device.dart';
|
|
||||||
import 'package:localsend_isolates/src/isolate/child/discovery_isolate.dart';
|
import 'package:localsend_isolates/src/isolate/child/discovery_isolate.dart';
|
||||||
import 'package:localsend_isolates/src/isolate/child/main.dart';
|
import 'package:localsend_isolates/src/isolate/child/main.dart';
|
||||||
import 'package:localsend_isolates/src/isolate/child/server_isolate.dart';
|
import 'package:localsend_isolates/src/isolate/child/server_isolate.dart';
|
||||||
@@ -18,7 +17,7 @@ part 'parent_isolate_provider.mapper.dart';
|
|||||||
@MappableClass()
|
@MappableClass()
|
||||||
class ParentIsolateState with ParentIsolateStateMappable {
|
class ParentIsolateState with ParentIsolateStateMappable {
|
||||||
final SyncState syncState;
|
final SyncState syncState;
|
||||||
final IsolateConnector<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<DiscoveryTask>>>? discovery;
|
final IsolateConnector<IsolateTaskStreamResult<DiscoveryResult>, SendToIsolateData<IsolateTask<DiscoveryTask>>>? discovery;
|
||||||
final IsolateConnector<IsolateTaskStreamResult<HttpUploadEvent>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>? httpUpload;
|
final IsolateConnector<IsolateTaskStreamResult<HttpUploadEvent>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>>? httpUpload;
|
||||||
final IsolateConnector<IsolateTaskStreamResult<HttpServerEvent>, SendToIsolateData<IsolateTask<BaseHttpServerTask>>>? httpServer;
|
final IsolateConnector<IsolateTaskStreamResult<HttpServerEvent>, SendToIsolateData<IsolateTask<BaseHttpServerTask>>>? httpServer;
|
||||||
|
|
||||||
@@ -62,7 +61,8 @@ class IsolateController extends ReduxNotifier<ParentIsolateState> {
|
|||||||
class IsolateSetupAction extends AsyncReduxAction<IsolateController, ParentIsolateState> {
|
class IsolateSetupAction extends AsyncReduxAction<IsolateController, ParentIsolateState> {
|
||||||
@override
|
@override
|
||||||
Future<ParentIsolateState> reduce() async {
|
Future<ParentIsolateState> reduce() async {
|
||||||
final discovery = await TypedIsolates.startIsolate<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<DiscoveryTask>>, InitialData>(
|
final discovery =
|
||||||
|
await TypedIsolates.startIsolate<IsolateTaskStreamResult<DiscoveryResult>, SendToIsolateData<IsolateTask<DiscoveryTask>>, InitialData>(
|
||||||
task: setupDiscoveryIsolate,
|
task: setupDiscoveryIsolate,
|
||||||
param: InitialData(
|
param: InitialData(
|
||||||
syncState: state.syncState,
|
syncState: state.syncState,
|
||||||
|
|||||||
+3
-4
@@ -16,7 +16,6 @@ class ParentIsolateStateMapper extends ClassMapperBase<ParentIsolateState> {
|
|||||||
if (_instance == null) {
|
if (_instance == null) {
|
||||||
MapperContainer.globals.use(_instance = ParentIsolateStateMapper._());
|
MapperContainer.globals.use(_instance = ParentIsolateStateMapper._());
|
||||||
SyncStateMapper.ensureInitialized();
|
SyncStateMapper.ensureInitialized();
|
||||||
DeviceMapper.ensureInitialized();
|
|
||||||
}
|
}
|
||||||
return _instance!;
|
return _instance!;
|
||||||
}
|
}
|
||||||
@@ -30,14 +29,14 @@ class ParentIsolateStateMapper extends ClassMapperBase<ParentIsolateState> {
|
|||||||
_$syncState,
|
_$syncState,
|
||||||
);
|
);
|
||||||
static IsolateConnector<
|
static IsolateConnector<
|
||||||
IsolateTaskStreamResult<Device>,
|
IsolateTaskStreamResult<DiscoveryResult>,
|
||||||
SendToIsolateData<IsolateTask<DiscoveryTask>>
|
SendToIsolateData<IsolateTask<DiscoveryTask>>
|
||||||
>?
|
>?
|
||||||
_$discovery(ParentIsolateState v) => v.discovery;
|
_$discovery(ParentIsolateState v) => v.discovery;
|
||||||
static const Field<
|
static const Field<
|
||||||
ParentIsolateState,
|
ParentIsolateState,
|
||||||
IsolateConnector<
|
IsolateConnector<
|
||||||
IsolateTaskStreamResult<Device>,
|
IsolateTaskStreamResult<DiscoveryResult>,
|
||||||
SendToIsolateData<IsolateTask<DiscoveryTask>>
|
SendToIsolateData<IsolateTask<DiscoveryTask>>
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
@@ -161,7 +160,7 @@ abstract class ParentIsolateStateCopyWith<
|
|||||||
$R call({
|
$R call({
|
||||||
SyncState? syncState,
|
SyncState? syncState,
|
||||||
IsolateConnector<
|
IsolateConnector<
|
||||||
IsolateTaskStreamResult<Device>,
|
IsolateTaskStreamResult<DiscoveryResult>,
|
||||||
SendToIsolateData<IsolateTask<DiscoveryTask>>
|
SendToIsolateData<IsolateTask<DiscoveryTask>>
|
||||||
>?
|
>?
|
||||||
discovery,
|
discovery,
|
||||||
|
|||||||
@@ -164,6 +164,19 @@ class DiscoveryService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The retained confirmations of a stored device, oldest first.
|
||||||
|
/// Empty when the fingerprint is unknown or the discovery is not running.
|
||||||
|
Future<List<DeviceLog>> deviceLogs(String fingerprint) async {
|
||||||
|
final discovery = _discovery;
|
||||||
|
if (discovery == null) {
|
||||||
|
_logger.info('Discovery is not running, skipping device logs');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final logs = await discovery.deviceLogs(fingerprint: fingerprint);
|
||||||
|
return logs.map((log) => log.toDeviceLog()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// Feeds a device confirmed outside of the discovery into the store, e.g.
|
/// Feeds a device confirmed outside of the discovery into the store, e.g.
|
||||||
/// one that registered with this device's HTTP server.
|
/// one that registered with this device's HTTP server.
|
||||||
/// The device comes back on the [startListener] stream.
|
/// The device comes back on the [startListener] stream.
|
||||||
|
|||||||
@@ -159,6 +159,23 @@ extension RsStoredDeviceExt on rust_discovery.RsStoredDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension RsDeviceLogExt on rust_discovery.RsDeviceLog {
|
||||||
|
DeviceLog toDeviceLog() {
|
||||||
|
return DeviceLog(
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampMillis.toInt()),
|
||||||
|
kind: switch (kind) {
|
||||||
|
rust_discovery.DeviceLogKind.discovered => DeviceLogKind.discovered,
|
||||||
|
rust_discovery.DeviceLogKind.updated => DeviceLogKind.updated,
|
||||||
|
},
|
||||||
|
channel: HttpChannel(
|
||||||
|
host: channel.host,
|
||||||
|
port: channel.port,
|
||||||
|
https: channel.protocol == rust_model.ProtocolType.https,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension DeviceToRsDiscoveredDeviceExt on Device {
|
extension DeviceToRsDiscoveredDeviceExt on Device {
|
||||||
rust_discovery.RsDiscoveredDevice toRsDiscoveredDevice(String ip) {
|
rust_discovery.RsDiscoveredDevice toRsDiscoveredDevice(String ip) {
|
||||||
return rust_discovery.RsDiscoveredDevice(
|
return rust_discovery.RsDiscoveredDevice(
|
||||||
|
|||||||
Reference in New Issue
Block a user