diff --git a/app/assets/i18n/en.json b/app/assets/i18n/en.json index d4d994ea..eb7be77c 100644 --- a/app/assets/i18n/en.json +++ b/app/assets/i18n/en.json @@ -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", diff --git a/app/lib/gen/strings.g.dart b/app/lib/gen/strings.g.dart index c6ffe3ea..74ddbbcb 100644 --- a/app/lib/gen/strings.g.dart +++ b/app/lib/gen/strings.g.dart @@ -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 diff --git a/app/lib/gen/strings_en.g.dart b/app/lib/gen/strings_en.g.dart index 3e9e7680..89cc8dae 100644 --- a/app/lib/gen/strings_en.g.dart +++ b/app/lib/gen/strings_en.g.dart @@ -58,6 +58,8 @@ class Translations with BaseTranslations { 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); diff --git a/app/lib/pages/device_details_page.dart b/app/lib/pages/device_details_page.dart new file mode 100644 index 00000000..80298939 --- /dev/null +++ b/app/lib/pages/device_details_page.dart @@ -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 createState() => _DeviceDetailsPageState(); +} + +class _DeviceDetailsPageState extends State with Refena { + List _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 _toggleFavorite(FavoriteDevice? favoriteEntry) async { + if (favoriteEntry != null) { + final result = await showDialog( + 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), + }; + } +} diff --git a/app/lib/pages/tabs/send_tab.dart b/app/lib/pages/tabs/send_tab.dart index b239ec1c..68ced8ec 100644 --- a/app/lib/pages/tabs/send_tab.dart +++ b/app/lib/pages/tabs/send_tab.dart @@ -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), ); } diff --git a/app/lib/pages/tabs/send_tab_vm.dart b/app/lib/pages/tabs/send_tab_vm.dart index ed4a7cf9..a9a2133c 100644 --- a/app/lib/pages/tabs/send_tab_vm.dart +++ b/app/lib/pages/tabs/send_tab_vm.dart @@ -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 Function(BuildContext context) onTapAddress; final Future Function(BuildContext context) onTapFavorite; final Future Function(BuildContext context, SendMode mode) onTapSendMode; - final Future Function(BuildContext context, Device device) onToggleFavorite; final Future Function(BuildContext context, Device device) onTapDevice; final Future 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( - 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()); diff --git a/app/lib/pages/verify_page.dart b/app/lib/pages/verify_page.dart new file mode 100644 index 00000000..a4b56fbb --- /dev/null +++ b/app/lib/pages/verify_page.dart @@ -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 createState() => _VerifyPageState(); +} + +class _VerifyPageState extends State { + _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), + }, + ], + ), + ); + } +} diff --git a/app/lib/util/fingerprint_alphabet.dart b/app/lib/util/fingerprint_alphabet.dart new file mode 100644 index 00000000..2a11f68f --- /dev/null +++ b/app/lib/util/fingerprint_alphabet.dart @@ -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 = [ + 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 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 convertFingerprintToAlphabet({ + required String fingerprint, + required List 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.filled(length, alphabet[0]); + for (var i = length - 1; i >= 0; i--) { + result[i] = alphabet[(value % base).toInt()]; + value ~/= base; + } + return result; +} diff --git a/app/lib/widget/list_tile/device_list_tile.dart b/app/lib/widget/list_tile/device_list_tile.dart index 965a83fd..9e74eabb 100644 --- a/app/lib/widget/list_tile/device_list_tile.dart +++ b/app/lib/widget/list_tile/device_list_tile.dart @@ -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( diff --git a/app/pubspec.lock b/app/pubspec.lock index 6b6fa76d..e10eb4ae 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -242,7 +242,7 @@ packages: source: hosted version: "0.3.5+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 43e8efb0..878da187 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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 diff --git a/app/test/unit/util/fingerprint_alphabet_test.dart b/app/test/unit/util/fingerprint_alphabet_test.dart new file mode 100644 index 00000000..e36ed703 --- /dev/null +++ b/app/test/unit/util/fingerprint_alphabet_test.dart @@ -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()); + } + }); + }); +} diff --git a/packages/localsend_isolates/lib/model/device.dart b/packages/localsend_isolates/lib/model/device.dart index d734997e..8665ce59 100644 --- a/packages/localsend_isolates/lib/model/device.dart +++ b/packages/localsend_isolates/lib/model/device.dart @@ -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') diff --git a/packages/localsend_isolates/lib/src/isolate/child/discovery_isolate.dart b/packages/localsend_isolates/lib/src/isolate/child/discovery_isolate.dart index b4f4f0c9..a611129b 100644 --- a/packages/localsend_isolates/lib/src/isolate/child/discovery_isolate.dart +++ b/packages/localsend_isolates/lib/src/isolate/child/discovery_isolate.dart @@ -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 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 setupDiscoveryIsolate( Stream>> receiveFromMain, - void Function(IsolateTaskStreamResult) sendToMain, + void Function(IsolateTaskStreamResult) sendToMain, InitialData initialData, ) async { await setupChildIsolateHelper( @@ -73,7 +107,7 @@ Future setupDiscoveryIsolate( sendToMain( IsolateTaskStreamResult.event( id: task.id, - data: device, + data: DiscoveryDeviceResult(device: device), ), ); } @@ -104,6 +138,15 @@ Future 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( diff --git a/packages/localsend_isolates/lib/src/isolate/parent/actions.dart b/packages/localsend_isolates/lib/src/isolate/parent/actions.dart index 09e332b6..88ddf727 100644 --- a/packages/localsend_isolates/lib/src/isolate/parent/actions.dart +++ b/packages/localsend_isolates/lib/src/isolate/parent/actions.dart @@ -25,9 +25,11 @@ class IsolateDiscoveryListenAction extends ReduxActionWithResult> { + final String fingerprint; + + IsolateDiscoveryDeviceLogsAction({ + required this.fingerprint, + }); + + @override + Future<(ParentIsolateState, List)> 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 { @@ -496,6 +529,13 @@ class IsolateHttpServerFailFileDownloadAction extends ReduxAction { + /// Unwraps the [DiscoveryDeviceResult]s of a device stream. + Stream toDeviceStream() { + return map((result) => (result as DiscoveryDeviceResult).device); + } +} + /// Adds the [SendToIsolateData] envelope on top of the generic /// [IsolateTaskConnector.sendTaskAndListenStream] from `typed_isolates`. extension _WrappedTaskConnector on IsolateConnector, SendToIsolateData>> { diff --git a/packages/localsend_isolates/lib/src/isolate/parent/parent_isolate_provider.dart b/packages/localsend_isolates/lib/src/isolate/parent/parent_isolate_provider.dart index ea47587f..ae7517fa 100644 --- a/packages/localsend_isolates/lib/src/isolate/parent/parent_isolate_provider.dart +++ b/packages/localsend_isolates/lib/src/isolate/parent/parent_isolate_provider.dart @@ -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, SendToIsolateData>>? discovery; + final IsolateConnector, SendToIsolateData>>? discovery; final IsolateConnector, SendToIsolateData>>? httpUpload; final IsolateConnector, SendToIsolateData>>? httpServer; @@ -62,13 +61,14 @@ class IsolateController extends ReduxNotifier { class IsolateSetupAction extends AsyncReduxAction { @override Future reduce() async { - final discovery = await TypedIsolates.startIsolate, SendToIsolateData>, InitialData>( - task: setupDiscoveryIsolate, - param: InitialData( - syncState: state.syncState, - logLevel: Logger.root.level, - ), - ); + final discovery = + await TypedIsolates.startIsolate, SendToIsolateData>, InitialData>( + task: setupDiscoveryIsolate, + param: InitialData( + syncState: state.syncState, + logLevel: Logger.root.level, + ), + ); final httpUpload = await TypedIsolates.startIsolate, SendToIsolateData>, InitialData>( diff --git a/packages/localsend_isolates/lib/src/isolate/parent/parent_isolate_provider.mapper.dart b/packages/localsend_isolates/lib/src/isolate/parent/parent_isolate_provider.mapper.dart index 50c13db7..ba8a10a4 100644 --- a/packages/localsend_isolates/lib/src/isolate/parent/parent_isolate_provider.mapper.dart +++ b/packages/localsend_isolates/lib/src/isolate/parent/parent_isolate_provider.mapper.dart @@ -16,7 +16,6 @@ class ParentIsolateStateMapper extends ClassMapperBase { if (_instance == null) { MapperContainer.globals.use(_instance = ParentIsolateStateMapper._()); SyncStateMapper.ensureInitialized(); - DeviceMapper.ensureInitialized(); } return _instance!; } @@ -30,14 +29,14 @@ class ParentIsolateStateMapper extends ClassMapperBase { _$syncState, ); static IsolateConnector< - IsolateTaskStreamResult, + IsolateTaskStreamResult, SendToIsolateData> >? _$discovery(ParentIsolateState v) => v.discovery; static const Field< ParentIsolateState, IsolateConnector< - IsolateTaskStreamResult, + IsolateTaskStreamResult, SendToIsolateData> > > @@ -161,7 +160,7 @@ abstract class ParentIsolateStateCopyWith< $R call({ SyncState? syncState, IsolateConnector< - IsolateTaskStreamResult, + IsolateTaskStreamResult, SendToIsolateData> >? discovery, diff --git a/packages/localsend_isolates/lib/src/task/discovery/discovery.dart b/packages/localsend_isolates/lib/src/task/discovery/discovery.dart index 8748a6fb..839af04c 100644 --- a/packages/localsend_isolates/lib/src/task/discovery/discovery.dart +++ b/packages/localsend_isolates/lib/src/task/discovery/discovery.dart @@ -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> 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. diff --git a/packages/localsend_isolates/lib/util/rust.dart b/packages/localsend_isolates/lib/util/rust.dart index 738d6620..359a31d6 100644 --- a/packages/localsend_isolates/lib/util/rust.dart +++ b/packages/localsend_isolates/lib/util/rust.dart @@ -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(