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": {
|
||||
"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": {
|
||||
"subTitle": {
|
||||
"one": "wants to send you a file",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 55
|
||||
/// Strings: 18560 (337 per locale)
|
||||
/// Strings: 18572 (337 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// 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$apkPickerPage$en apkPickerPage = Translations$apkPickerPage$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$receiveOptionsPage$en receiveOptionsPage = Translations$receiveOptionsPage$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';
|
||||
}
|
||||
|
||||
// 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
|
||||
class Translations$receivePage$en {
|
||||
Translations$receivePage$en.internal(this._root);
|
||||
@@ -1268,6 +1309,42 @@ class Translations$receiveHistoryPage$entryActions$en {
|
||||
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
|
||||
class Translations$progressPage$total$en {
|
||||
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/gen/strings.g.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/tabs/send_tab_vm.dart';
|
||||
import 'package:localsend_app/pages/troubleshoot_page.dart';
|
||||
@@ -215,15 +216,13 @@ class SendTab extends StatelessWidget {
|
||||
child: vm.sendMode == SendMode.multiple
|
||||
? _MultiSendDeviceListTile(
|
||||
device: device,
|
||||
isFavorite: favoriteEntry != null,
|
||||
nameOverride: favoriteEntry?.alias,
|
||||
vm: vm,
|
||||
)
|
||||
: DeviceListTile(
|
||||
device: device,
|
||||
isFavorite: favoriteEntry != null,
|
||||
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),
|
||||
),
|
||||
),
|
||||
@@ -514,13 +513,11 @@ class _SendModeButton extends StatelessWidget {
|
||||
/// An advanced list tile which shows the progress of the file transfer.
|
||||
class _MultiSendDeviceListTile extends StatelessWidget {
|
||||
final Device device;
|
||||
final bool isFavorite;
|
||||
final String? nameOverride;
|
||||
final SendTabVm vm;
|
||||
|
||||
const _MultiSendDeviceListTile({
|
||||
required this.device,
|
||||
required this.isFavorite,
|
||||
required this.nameOverride,
|
||||
required this.vm,
|
||||
});
|
||||
@@ -551,9 +548,8 @@ class _MultiSendDeviceListTile extends StatelessWidget {
|
||||
device: device,
|
||||
info: info,
|
||||
progress: progress,
|
||||
isFavorite: isFavorite,
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/selection/selected_sending_files_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/favorite_delete_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_isolates/model/device.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) onTapFavorite;
|
||||
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) onTapDeviceMultiSend;
|
||||
|
||||
@@ -46,7 +42,6 @@ class SendTabVm {
|
||||
required this.onTapAddress,
|
||||
required this.onTapFavorite,
|
||||
required this.onTapSendMode,
|
||||
required this.onToggleFavorite,
|
||||
required this.onTapDevice,
|
||||
required this.onTapDeviceMultiSend,
|
||||
});
|
||||
@@ -122,23 +117,6 @@ final sendTabVmProvider = ViewProvider((ref) {
|
||||
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 {
|
||||
if (selectedFiles.isEmpty) {
|
||||
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 {
|
||||
final Device device;
|
||||
final bool isFavorite;
|
||||
|
||||
/// If not null, this name is used instead of [Device.alias].
|
||||
/// This is the case when the device is marked as favorite.
|
||||
@@ -16,16 +15,15 @@ class DeviceListTile extends StatelessWidget {
|
||||
final String? info;
|
||||
final double? progress;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onFavoriteTap;
|
||||
final VoidCallback? onDetailsTap;
|
||||
|
||||
const DeviceListTile({
|
||||
required this.device,
|
||||
this.isFavorite = false,
|
||||
this.nameOverride,
|
||||
this.info,
|
||||
this.progress,
|
||||
this.onTap,
|
||||
this.onFavoriteTap,
|
||||
this.onDetailsTap,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -34,10 +32,10 @@ class DeviceListTile extends StatelessWidget {
|
||||
return CustomListTile(
|
||||
icon: Icon(device.deviceType.icon, size: 46),
|
||||
title: Text(nameOverride ?? device.alias, style: const TextStyle(fontSize: 20)),
|
||||
trailing: onFavoriteTap != null
|
||||
trailing: onDetailsTap != null
|
||||
? IconButton(
|
||||
icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border),
|
||||
onPressed: onFavoriteTap,
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: onDetailsTap,
|
||||
)
|
||||
: null,
|
||||
subTitle: Wrap(
|
||||
|
||||
+1
-1
@@ -242,7 +242,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.5+4"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
||||
@@ -12,6 +12,7 @@ environment:
|
||||
dependencies:
|
||||
collection: ^1.17.2 # allow newer versions, so it can compile with newer Flutter versions
|
||||
connectivity_plus: 7.2.0
|
||||
crypto: 3.0.7
|
||||
dart_mappable: 4.8.0
|
||||
desktop_drop: 0.7.1
|
||||
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});
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
http('HTTP'),
|
||||
webrtc('WebRTC')
|
||||
|
||||
@@ -6,6 +6,29 @@ import 'package:typed_isolates/typed_isolates.dart';
|
||||
|
||||
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.
|
||||
/// The stream never completes; it survives [DiscoveryRestartTask]s.
|
||||
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(
|
||||
Stream<SendToIsolateData<IsolateTask<DiscoveryTask>>> receiveFromMain,
|
||||
void Function(IsolateTaskStreamResult<Device>) sendToMain,
|
||||
void Function(IsolateTaskStreamResult<DiscoveryResult>) sendToMain,
|
||||
InitialData initialData,
|
||||
) async {
|
||||
await setupChildIsolateHelper(
|
||||
@@ -73,7 +107,7 @@ Future<void> setupDiscoveryIsolate(
|
||||
sendToMain(
|
||||
IsolateTaskStreamResult.event(
|
||||
id: task.id,
|
||||
data: device,
|
||||
data: DiscoveryDeviceResult(device: device),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -104,6 +138,15 @@ Future<void> setupDiscoveryIsolate(
|
||||
case DiscoveryAddDeviceTask data:
|
||||
await ref.read(discoveryProvider).addDevice(data.device);
|
||||
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(
|
||||
IsolateTaskStreamResult.done(
|
||||
|
||||
@@ -25,9 +25,11 @@ class IsolateDiscoveryListenAction extends ReduxActionWithResult<IsolateControll
|
||||
|
||||
return (
|
||||
state,
|
||||
connection.sendWrappedTaskAndListenStream(
|
||||
task: DiscoveryListenTask(),
|
||||
),
|
||||
connection
|
||||
.sendWrappedTaskAndListenStream(
|
||||
task: DiscoveryListenTask(),
|
||||
)
|
||||
.toDeviceStream(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,13 +58,15 @@ class IsolateDiscoverySubnetScanAction extends ReduxActionWithResult<IsolateCont
|
||||
|
||||
return (
|
||||
state,
|
||||
connection.sendWrappedTaskAndListenStream(
|
||||
task: DiscoverySubnetScanTask(
|
||||
networkInterface: networkInterface,
|
||||
port: port,
|
||||
https: https,
|
||||
),
|
||||
),
|
||||
connection
|
||||
.sendWrappedTaskAndListenStream(
|
||||
task: DiscoverySubnetScanTask(
|
||||
networkInterface: networkInterface,
|
||||
port: port,
|
||||
https: https,
|
||||
),
|
||||
)
|
||||
.toDeviceStream(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,16 +92,45 @@ class IsolateDiscoveryFavoriteScanAction extends ReduxActionWithResult<IsolateCo
|
||||
|
||||
return (
|
||||
state,
|
||||
connection.sendWrappedTaskAndListenStream(
|
||||
task: DiscoveryFavoriteScanTask(
|
||||
favorites: favorites,
|
||||
https: https,
|
||||
),
|
||||
),
|
||||
connection
|
||||
.sendWrappedTaskAndListenStream(
|
||||
task: DiscoveryFavoriteScanTask(
|
||||
favorites: favorites,
|
||||
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
|
||||
/// network register with this device's HTTP server.
|
||||
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
|
||||
/// [IsolateTaskConnector.sendTaskAndListenStream] from `typed_isolates`.
|
||||
extension _WrappedTaskConnector<R, T> on IsolateConnector<IsolateTaskStreamResult<R>, SendToIsolateData<IsolateTask<T>>> {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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/main.dart';
|
||||
import 'package:localsend_isolates/src/isolate/child/server_isolate.dart';
|
||||
@@ -18,7 +17,7 @@ part 'parent_isolate_provider.mapper.dart';
|
||||
@MappableClass()
|
||||
class ParentIsolateState with ParentIsolateStateMappable {
|
||||
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<HttpServerEvent>, SendToIsolateData<IsolateTask<BaseHttpServerTask>>>? httpServer;
|
||||
|
||||
@@ -62,13 +61,14 @@ class IsolateController extends ReduxNotifier<ParentIsolateState> {
|
||||
class IsolateSetupAction extends AsyncReduxAction<IsolateController, ParentIsolateState> {
|
||||
@override
|
||||
Future<ParentIsolateState> reduce() async {
|
||||
final discovery = await TypedIsolates.startIsolate<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<DiscoveryTask>>, InitialData>(
|
||||
task: setupDiscoveryIsolate,
|
||||
param: InitialData(
|
||||
syncState: state.syncState,
|
||||
logLevel: Logger.root.level,
|
||||
),
|
||||
);
|
||||
final discovery =
|
||||
await TypedIsolates.startIsolate<IsolateTaskStreamResult<DiscoveryResult>, SendToIsolateData<IsolateTask<DiscoveryTask>>, InitialData>(
|
||||
task: setupDiscoveryIsolate,
|
||||
param: InitialData(
|
||||
syncState: state.syncState,
|
||||
logLevel: Logger.root.level,
|
||||
),
|
||||
);
|
||||
|
||||
final httpUpload =
|
||||
await TypedIsolates.startIsolate<IsolateTaskStreamResult<HttpUploadEvent>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>, InitialData>(
|
||||
|
||||
+3
-4
@@ -16,7 +16,6 @@ class ParentIsolateStateMapper extends ClassMapperBase<ParentIsolateState> {
|
||||
if (_instance == null) {
|
||||
MapperContainer.globals.use(_instance = ParentIsolateStateMapper._());
|
||||
SyncStateMapper.ensureInitialized();
|
||||
DeviceMapper.ensureInitialized();
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
@@ -30,14 +29,14 @@ class ParentIsolateStateMapper extends ClassMapperBase<ParentIsolateState> {
|
||||
_$syncState,
|
||||
);
|
||||
static IsolateConnector<
|
||||
IsolateTaskStreamResult<Device>,
|
||||
IsolateTaskStreamResult<DiscoveryResult>,
|
||||
SendToIsolateData<IsolateTask<DiscoveryTask>>
|
||||
>?
|
||||
_$discovery(ParentIsolateState v) => v.discovery;
|
||||
static const Field<
|
||||
ParentIsolateState,
|
||||
IsolateConnector<
|
||||
IsolateTaskStreamResult<Device>,
|
||||
IsolateTaskStreamResult<DiscoveryResult>,
|
||||
SendToIsolateData<IsolateTask<DiscoveryTask>>
|
||||
>
|
||||
>
|
||||
@@ -161,7 +160,7 @@ abstract class ParentIsolateStateCopyWith<
|
||||
$R call({
|
||||
SyncState? syncState,
|
||||
IsolateConnector<
|
||||
IsolateTaskStreamResult<Device>,
|
||||
IsolateTaskStreamResult<DiscoveryResult>,
|
||||
SendToIsolateData<IsolateTask<DiscoveryTask>>
|
||||
>?
|
||||
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.
|
||||
/// one that registered with this device's HTTP server.
|
||||
/// 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 {
|
||||
rust_discovery.RsDiscoveredDevice toRsDiscoveredDevice(String ip) {
|
||||
return rust_discovery.RsDiscoveredDevice(
|
||||
|
||||
Reference in New Issue
Block a user