Преглед изворни кода

Merge branch 'main' into 30_million

Vishnu Mohandas пре 1 година
родитељ
комит
f2c05c49a3

+ 50 - 0
lib/gateways/cast_gw.dart

@@ -0,0 +1,50 @@
+import "package:dio/dio.dart";
+
+class CastGateway {
+  final Dio _enteDio;
+
+  CastGateway(this._enteDio);
+
+  Future<String?> getPublicKey(String deviceCode) async {
+    try {
+      final response = await _enteDio.get(
+        "/cast/device-info/$deviceCode",
+      );
+      return response.data["publicKey"];
+    } catch (e) {
+      if (e is DioError &&
+          e.response != null &&
+          e.response!.statusCode == 404) {
+        return null;
+      }
+      rethrow;
+    }
+  }
+
+  Future<void> publishCastPayload(
+    String code,
+    String castPayload,
+    int collectionID,
+    String castToken,
+  ) {
+    return _enteDio.post(
+      "/cast/cast-data/",
+      data: {
+        "deviceCode": code,
+        "encPayload": castPayload,
+        "collectionID": collectionID,
+        "castToken": castToken,
+      },
+    );
+  }
+
+  Future<void> revokeAllTokens() async {
+    try {
+      await _enteDio.delete(
+        "/cast/revoke-all-tokens/",
+      );
+    } catch (e) {
+      // swallow error
+    }
+  }
+}

+ 8 - 0
lib/generated/intl/messages_en.dart

@@ -380,6 +380,8 @@ class MessageLookup extends MessageLookupByLibrary {
         "cannotAddMorePhotosAfterBecomingViewer": m7,
         "cannotDeleteSharedFiles":
             MessageLookupByLibrary.simpleMessage("Cannot delete shared files"),
+        "castInstruction": MessageLookupByLibrary.simpleMessage(
+            "Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV."),
         "centerPoint": MessageLookupByLibrary.simpleMessage("Center point"),
         "changeEmail": MessageLookupByLibrary.simpleMessage("Change email"),
         "changeLocationOfSelectedItems": MessageLookupByLibrary.simpleMessage(
@@ -552,10 +554,14 @@ class MessageLookup extends MessageLookupByLibrary {
         "details": MessageLookupByLibrary.simpleMessage("Details"),
         "devAccountChanged": MessageLookupByLibrary.simpleMessage(
             "The developer account we use to publish ente on App Store has changed. Because of this, you will need to login again.\n\nOur apologies for the inconvenience, but this was unavoidable."),
+        "deviceCodeHint":
+            MessageLookupByLibrary.simpleMessage("Enter the code"),
         "deviceFilesAutoUploading": MessageLookupByLibrary.simpleMessage(
             "Files added to this device album will automatically get uploaded to ente."),
         "deviceLockExplanation": MessageLookupByLibrary.simpleMessage(
             "Disable the device screen lock when ente is in the foreground and there is a backup in progress. This is normally not needed, but may help big uploads and initial imports of large libraries complete faster."),
+        "deviceNotFound":
+            MessageLookupByLibrary.simpleMessage("Device not found"),
         "didYouKnow": MessageLookupByLibrary.simpleMessage("Did you know?"),
         "disableAutoLock":
             MessageLookupByLibrary.simpleMessage("Disable auto lock"),
@@ -946,6 +952,7 @@ class MessageLookup extends MessageLookupByLibrary {
             "Optional, as short as you like..."),
         "orPickAnExistingOne":
             MessageLookupByLibrary.simpleMessage("Or pick an existing one"),
+        "pair": MessageLookupByLibrary.simpleMessage("Pair"),
         "password": MessageLookupByLibrary.simpleMessage("Password"),
         "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage(
             "Password changed successfully"),
@@ -980,6 +987,7 @@ class MessageLookup extends MessageLookupByLibrary {
         "pickCenterPoint":
             MessageLookupByLibrary.simpleMessage("Pick center point"),
         "pinAlbum": MessageLookupByLibrary.simpleMessage("Pin album"),
+        "playOnTv": MessageLookupByLibrary.simpleMessage("Play album on TV"),
         "playStoreFreeTrialValidTill": m37,
         "playstoreSubscription":
             MessageLookupByLibrary.simpleMessage("PlayStore subscription"),

+ 50 - 0
lib/generated/l10n.dart

@@ -8307,6 +8307,56 @@ class S {
       args: [],
     );
   }
+
+  /// `Play album on TV`
+  String get playOnTv {
+    return Intl.message(
+      'Play album on TV',
+      name: 'playOnTv',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Pair`
+  String get pair {
+    return Intl.message(
+      'Pair',
+      name: 'pair',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Device not found`
+  String get deviceNotFound {
+    return Intl.message(
+      'Device not found',
+      name: 'deviceNotFound',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV.`
+  String get castInstruction {
+    return Intl.message(
+      'Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV.',
+      name: 'castInstruction',
+      desc: '',
+      args: [],
+    );
+  }
+
+  /// `Enter the code`
+  String get deviceCodeHint {
+    return Intl.message(
+      'Enter the code',
+      name: 'deviceCodeHint',
+      desc: '',
+      args: [],
+    );
+  }
 }
 
 class AppLocalizationDelegate extends LocalizationsDelegate<S> {

+ 6 - 1
lib/l10n/intl_en.arb

@@ -1187,5 +1187,10 @@
   "selectALocationFirst": "Select a location first",
   "changeLocationOfSelectedItems": "Change location of selected items?",
   "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente",
-  "cleanUncategorized": "Clean Uncategorized"
+  "cleanUncategorized": "Clean Uncategorized",
+  "playOnTv": "Play album on TV",
+  "pair": "Pair",
+  "deviceNotFound": "Device not found",
+  "castInstruction": "Visit cast.ente.io on the device you want to pair.\n\nEnter the code below to play the album on your TV.",
+  "deviceCodeHint": "Enter the code"
 }

+ 17 - 0
lib/services/collections_service.dart

@@ -473,6 +473,23 @@ class CollectionsService {
     });
   }
 
+  String getCastData(
+    String castToken,
+    Collection collection,
+    String publicKey,
+  ) {
+    final String payload = jsonEncode({
+      "collectionID": collection.id,
+      "castToken": castToken,
+      "collectionKey": CryptoUtil.bin2base64(getCollectionKey(collection.id)),
+    });
+    final encPayload = CryptoUtil.sealSync(
+      CryptoUtil.base642bin(base64Encode(payload.codeUnits)),
+      CryptoUtil.base642bin(publicKey),
+    );
+    return CryptoUtil.bin2base64(encPayload);
+  }
+
   Future<List<User>> share(
     int collectionID,
     String email,

+ 1 - 4
lib/services/location_service.dart

@@ -13,7 +13,6 @@ import "package:photos/models/local_entity_data.dart";
 import "package:photos/models/location/location.dart";
 import 'package:photos/models/location_tag/location_tag.dart';
 import "package:photos/services/entity_service.dart";
-import "package:photos/services/feature_flag_service.dart";
 import "package:photos/services/remote_assets_service.dart";
 import "package:shared_preferences/shared_preferences.dart";
 
@@ -32,9 +31,7 @@ class LocationService {
 
   void init(SharedPreferences preferences) {
     prefs = preferences;
-    if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
-      _loadCities();
-    }
+    _loadCities();
   }
 
   Future<Iterable<LocalEntity<LocationTag>>> _getStoredLocationTags() async {

+ 52 - 35
lib/ui/map/map_screen.dart

@@ -2,6 +2,7 @@ import "dart:async";
 import "dart:isolate";
 
 import "package:collection/collection.dart";
+import "package:computer/computer.dart";
 import "package:flutter/foundation.dart";
 import 'package:flutter/material.dart';
 import 'package:flutter_map/flutter_map.dart';
@@ -79,43 +80,15 @@ class _MapScreenState extends State<MapScreen> {
   }
 
   Future<void> processFiles(List<EnteFile> files) async {
-    final List<ImageMarker> tempMarkers = [];
-    bool hasAnyLocation = false;
-    EnteFile? mostRecentFile;
-    for (var file in files) {
-      if (file.hasLocation) {
-        if (!Location.isValidRange(
-          latitude: file.location!.latitude!,
-          longitude: file.location!.longitude!,
-        )) {
-          _logger.warning(
-            'Skipping file with invalid location ${file.toString()}',
-          );
-          continue;
-        }
-        hasAnyLocation = true;
-
-        if (widget.center == null) {
-          if (mostRecentFile == null) {
-            mostRecentFile = file;
-          } else {
-            if ((mostRecentFile.creationTime ?? 0) < (file.creationTime ?? 0)) {
-              mostRecentFile = file;
-            }
-          }
-        }
+    final result = await Computer.shared().compute(
+      _findRecentFileAndGenerateTempMarkers,
+      param: {"files": files, "center": widget.center},
+    );
 
-        tempMarkers.add(
-          ImageMarker(
-            latitude: file.location!.latitude!,
-            longitude: file.location!.longitude!,
-            imageFile: file,
-          ),
-        );
-      }
-    }
+    final EnteFile? mostRecentFile = result.$1;
+    final List<ImageMarker> tempMarkers = result.$2;
 
-    if (hasAnyLocation) {
+    if (tempMarkers.isNotEmpty) {
       center = widget.center ??
           LatLng(
             mostRecentFile!.location!.latitude!,
@@ -173,6 +146,50 @@ class _MapScreenState extends State<MapScreen> {
     });
   }
 
+  static (EnteFile?, List<ImageMarker>) _findRecentFileAndGenerateTempMarkers(
+    Map<String, dynamic> args,
+  ) {
+    final Logger logger = Logger("_MapScreenState");
+    final files = args["files"] as List<EnteFile>;
+    final center = args["center"] as LatLng?;
+    final List<ImageMarker> tempMarkers = [];
+    EnteFile? mostRecentFile;
+
+    for (var file in files) {
+      if (file.hasLocation) {
+        if (!Location.isValidRange(
+          latitude: file.location!.latitude!,
+          longitude: file.location!.longitude!,
+        )) {
+          logger.warning(
+            'Skipping file with invalid location ${file.toString()}',
+          );
+          continue;
+        }
+
+        if (center == null) {
+          if (mostRecentFile == null) {
+            mostRecentFile = file;
+          } else {
+            if ((mostRecentFile.creationTime ?? 0) < (file.creationTime ?? 0)) {
+              mostRecentFile = file;
+            }
+          }
+        }
+
+        tempMarkers.add(
+          ImageMarker(
+            latitude: file.location!.latitude!,
+            longitude: file.location!.longitude!,
+            imageFile: file,
+          ),
+        );
+      }
+    }
+
+    return (mostRecentFile, tempMarkers);
+  }
+
   @pragma('vm:entry-point')
   static void _calculateMarkersIsolate(MapIsolate message) async {
     final bounds = message.bounds;

+ 0 - 10
lib/ui/viewer/file_details/location_tags_widget.dart

@@ -14,9 +14,7 @@ import "package:photos/services/location_service.dart";
 import "package:photos/services/search_service.dart";
 import "package:photos/services/user_remote_flag_service.dart";
 import "package:photos/states/location_screen_state.dart";
-import "package:photos/theme/colors.dart";
 import "package:photos/theme/ente_theme.dart";
-import "package:photos/ui/common/loading_widget.dart";
 import "package:photos/ui/components/buttons/chip_button_widget.dart";
 import "package:photos/ui/components/info_item_widget.dart";
 import "package:photos/ui/map/enable_map.dart";
@@ -255,14 +253,6 @@ class _InfoMapState extends State<InfoMap> {
                         ),
                       ),
                     ),
-                    _tappedToOpenMap
-                        ? const EnteLoadingWidget(
-                            alignment: Alignment.topLeft,
-                            padding: 19,
-                            size: 11,
-                            color: strokeSolidMutedLight,
-                          )
-                        : const SizedBox.shrink(),
                   ],
                 )
               : ValueListenableBuilder(

+ 60 - 0
lib/ui/viewer/gallery/gallery_app_bar_widget.dart

@@ -6,10 +6,14 @@ import "package:flutter/cupertino.dart";
 import 'package:flutter/material.dart';
 import 'package:logging/logging.dart';
 import 'package:photos/core/configuration.dart';
+import "package:photos/core/constants.dart";
 import 'package:photos/core/event_bus.dart';
+import "package:photos/core/network/network.dart";
 import "package:photos/db/files_db.dart";
 import 'package:photos/events/subscription_purchased_event.dart';
+import "package:photos/gateways/cast_gw.dart";
 import "package:photos/generated/l10n.dart";
+import "package:photos/l10n/l10n.dart";
 import 'package:photos/models/backup_status.dart';
 import 'package:photos/models/collection/collection.dart';
 import 'package:photos/models/device_collection.dart';
@@ -36,6 +40,7 @@ import 'package:photos/utils/dialog_util.dart';
 import 'package:photos/utils/magic_util.dart';
 import 'package:photos/utils/navigation_util.dart';
 import 'package:photos/utils/toast_util.dart';
+import "package:uuid/uuid.dart";
 
 class GalleryAppBarWidget extends StatefulWidget {
   final GalleryType type;
@@ -64,6 +69,7 @@ enum AlbumPopupAction {
   ownedArchive,
   sharedArchive,
   ownedHide,
+  playOnTv,
   sort,
   leave,
   freeUpSpace,
@@ -472,6 +478,22 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
         ),
       );
     }
+    if (widget.collection != null && isInternalUser) {
+      items.add(
+        PopupMenuItem(
+          value: AlbumPopupAction.playOnTv,
+          child: Row(
+            children: [
+              const Icon(Icons.tv_outlined),
+              const Padding(
+                padding: EdgeInsets.all(8),
+              ),
+              Text(context.l10n.playOnTv),
+            ],
+          ),
+        ),
+      );
+    }
 
     if (galleryType.canDelete()) {
       items.add(
@@ -579,6 +601,8 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
               await _removeQuickLink();
             } else if (value == AlbumPopupAction.leave) {
               await _leaveAlbum(context);
+            } else if (value == AlbumPopupAction.playOnTv) {
+              await castAlbum();
             } else if (value == AlbumPopupAction.freeUpSpace) {
               await _deleteBackedUpFiles(context);
             } else if (value == AlbumPopupAction.setCover) {
@@ -797,4 +821,40 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
     );
     setState(() {});
   }
+
+  Future<void> castAlbum() async {
+    final gw = CastGateway(NetworkClient.instance.enteDio);
+    // stop any existing cast session
+    gw.revokeAllTokens().ignore();
+    await showTextInputDialog(
+      context,
+      title: context.l10n.playOnTv,
+      body: S.of(context).castInstruction,
+      submitButtonLabel: S.of(context).pair,
+      textInputType: TextInputType.streetAddress,
+      hintText: context.l10n.deviceCodeHint,
+      onSubmit: (String text) async {
+        try {
+          String code = text.trim();
+          final String? publicKey = await gw.getPublicKey(code);
+          if (publicKey == null) {
+            showToast(context, S.of(context).deviceNotFound);
+            return;
+          }
+          final String castToken = Uuid().v4().toString();
+          final castPayload = CollectionsService.instance
+              .getCastData(castToken, widget.collection!, publicKey);
+          await gw.publishCastPayload(
+            code,
+            castPayload,
+            widget.collection!.id,
+            castToken,
+          );
+        } catch (e, s) {
+          _logger.severe("Failed to cast album", e, s);
+          await showGenericErrorDialog(context: context, error: e);
+        }
+      },
+    );
+  }
 }

+ 39 - 1
lib/utils/exif_util.dart

@@ -11,6 +11,11 @@ import 'package:photos/utils/file_util.dart';
 
 const kDateTimeOriginal = "EXIF DateTimeOriginal";
 const kImageDateTime = "Image DateTime";
+const kExifOffSetKeys = [
+  "EXIF OffsetTime",
+  "EXIF OffsetTimeOriginal",
+  "EXIF OffsetTimeDigitized",
+];
 const kExifDateTimePattern = "yyyy:MM:dd HH:mm:ss";
 const kEmptyExifDateTime = "0000:00:00 00:00:00";
 
@@ -56,7 +61,14 @@ Future<DateTime?> getCreationTimeFromEXIF(
             ? exif[kImageDateTime]!.printable
             : null;
     if (exifTime != null && exifTime != kEmptyExifDateTime) {
-      return DateFormat(kExifDateTimePattern).parse(exifTime);
+      String? exifOffsetTime;
+      for (final key in kExifOffSetKeys) {
+        if (exif.containsKey(key)) {
+          exifOffsetTime = exif[key]!.printable;
+          break;
+        }
+      }
+      return getDateTimeInDeviceTimezone(exifTime, exifOffsetTime);
     }
   } catch (e) {
     _logger.severe("failed to getCreationTimeFromEXIF", e);
@@ -64,6 +76,32 @@ Future<DateTime?> getCreationTimeFromEXIF(
   return null;
 }
 
+DateTime getDateTimeInDeviceTimezone(String exifTime, String? offsetString) {
+  final DateTime result = DateFormat(kExifDateTimePattern).parse(exifTime);
+  if (offsetString == null) {
+    return result;
+  }
+  try {
+    final List<String> splitHHMM = offsetString.split(":");
+    // Parse the offset from the photo's time zone
+    final int offsetHours = int.parse(splitHHMM[0]);
+    final int offsetMinutes =
+        int.parse(splitHHMM[1]) * (offsetHours.isNegative ? -1 : 1);
+    // Adjust the date for the offset to get the photo's correct UTC time
+    final photoUtcDate =
+        result.add(Duration(hours: -offsetHours, minutes: -offsetMinutes));
+    // Getting the current device's time zone offset from UTC
+    final now = DateTime.now();
+    final localOffset = now.timeZoneOffset;
+    // Adjusting the photo's UTC time to the device's local time
+    final deviceLocalTime = photoUtcDate.add(localOffset);
+    return deviceLocalTime;
+  } catch (e, s) {
+    _logger.severe("tz offset adjust failed $offsetString", e, s);
+  }
+  return result;
+}
+
 Location? locationFromExif(Map<String, IfdTag> exif) {
   try {
     return gpsDataFromExif(exif).toLocationObj();

+ 1 - 1
pubspec.yaml

@@ -12,7 +12,7 @@ description: ente photos application
 # Read more about iOS versioning at
 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
 
-version: 0.8.51+571
+version: 0.8.52+572
 
 environment:
   sdk: ">=3.0.0 <4.0.0"