feat: add device detail page

This commit is contained in:
Tien Do Nam
2026-08-04 16:05:21 +02:00
parent 966543da6a
commit 56af4ef616
19 changed files with 877 additions and 69 deletions
+20
View File
@@ -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",
+1 -1
View 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
+77
View File
@@ -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);
+153
View File
@@ -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 -7
View File
@@ -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),
); );
} }
-22
View File
@@ -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());
+73
View File
@@ -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),
},
],
),
);
}
}
+305
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
task: DiscoveryListenTask(), .sendWrappedTaskAndListenStream(
), task: DiscoveryListenTask(),
)
.toDeviceStream(),
); );
} }
} }
@@ -56,13 +58,15 @@ class IsolateDiscoverySubnetScanAction extends ReduxActionWithResult<IsolateCont
return ( return (
state, state,
connection.sendWrappedTaskAndListenStream( connection
task: DiscoverySubnetScanTask( .sendWrappedTaskAndListenStream(
networkInterface: networkInterface, task: DiscoverySubnetScanTask(
port: port, networkInterface: networkInterface,
https: https, port: port,
), https: https,
), ),
)
.toDeviceStream(),
); );
} }
} }
@@ -88,16 +92,45 @@ class IsolateDiscoveryFavoriteScanAction extends ReduxActionWithResult<IsolateCo
return ( return (
state, state,
connection.sendWrappedTaskAndListenStream( connection
task: DiscoveryFavoriteScanTask( .sendWrappedTaskAndListenStream(
favorites: favorites, task: DiscoveryFavoriteScanTask(
https: https, 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 /// 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,13 +61,14 @@ 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 =
task: setupDiscoveryIsolate, await TypedIsolates.startIsolate<IsolateTaskStreamResult<DiscoveryResult>, SendToIsolateData<IsolateTask<DiscoveryTask>>, InitialData>(
param: InitialData( task: setupDiscoveryIsolate,
syncState: state.syncState, param: InitialData(
logLevel: Logger.root.level, syncState: state.syncState,
), logLevel: Logger.root.level,
); ),
);
final httpUpload = final httpUpload =
await TypedIsolates.startIsolate<IsolateTaskStreamResult<HttpUploadEvent>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>, InitialData>( await TypedIsolates.startIsolate<IsolateTaskStreamResult<HttpUploadEvent>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>, InitialData>(
@@ -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(