[mob][photos] fuction to handle deeplinks

This commit is contained in:
Aman Raj Singh Mourya 2024-09-21 19:28:09 +05:30
parent 130418e443
commit add3278c89
7 changed files with 412 additions and 12 deletions

View File

@ -4,6 +4,7 @@ import 'dart:math';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import "package:fast_base58/fast_base58.dart";
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:photos/core/configuration.dart';
@ -64,6 +65,8 @@ class CollectionsService {
Collection? cachedUncategorizedCollection;
final Map<String, EnteFile> _coverCache = <String, EnteFile>{};
final Map<int, int> _countCache = <int, int>{};
final _cachedPublicAlbumToken = <int, String>{};
final _cachedPublicAlbumJWTToken = <int, String>{};
CollectionsService._privateConstructor() {
_db = CollectionsDB.instance;
@ -172,6 +175,8 @@ class CollectionsService {
_collectionIDToCollections.clear();
cachedDefaultHiddenCollection = null;
cachedUncategorizedCollection = null;
_cachedPublicAlbumToken.clear();
_cachedPublicAlbumJWTToken.clear();
_cachedKeys.clear();
}
@ -1030,6 +1035,84 @@ class CollectionsService {
}
}
Future<Collection> getPublicCollection(Uri uri) async {
final String authToken = uri.queryParameters["t"] ?? "Not found";
final String albumKey = uri.fragment;
try {
final response = await _enteDio.get(
"/public-collection/info",
options: Options(
headers: {"X-Auth-Access-Token": authToken},
),
);
final collectionData = response.data["collection"];
final Collection collection = Collection.fromMap(collectionData);
final Uint8List collectionKey =
Uint8List.fromList(Base58Decode(albumKey));
_logger.severe("Public collection fetched: $collectionData");
_cachedKeys[collection.id] = collectionKey;
_cachedPublicAlbumToken[collection.id] = authToken;
return collection;
} catch (e, s) {
_logger.warning(e, s);
_logger.severe("Failed to fetch public collection");
if (e is DioError && e.response?.statusCode == 401) {
throw UnauthorizedError();
}
rethrow;
}
}
Future<bool> verifyPublicCollectionPassword(
String passwordHash,
int collectioID,
) async {
final authToken = await getPublicAlbumToken(collectioID);
try {
final response = await _enteDio.post(
"https://api.ente.io/public-collection/verify-password",
data: {"passHash": passwordHash},
options: Options(
headers: {
"X-Auth-Access-Token": authToken,
"Cache-Control": "no-cache",
},
),
);
final jwtToken = response.data["jwtToken"];
_logger.severe("TOKEN $authToken");
_logger.severe("JWT TOKEN $jwtToken");
if (response.statusCode == 200) {
await setPublicAlbumTokenJWT(collectioID, jwtToken);
return true;
}
return false;
} catch (e) {
_logger.severe("Failed to verify public collection password $e");
return false;
}
}
Future<String?> getPublicAlbumToken(int collectionID) async {
if (_cachedPublicAlbumToken.containsKey(collectionID)) {
return _cachedPublicAlbumToken[collectionID];
}
return null;
}
Future<String?> getPublicAlbumTokenJWT(int collectionID) async {
if (_cachedPublicAlbumJWTToken.containsKey(collectionID)) {
return _cachedPublicAlbumJWTToken[collectionID];
}
return null;
}
Future<void> setPublicAlbumTokenJWT(int collectionID, String token) async {
_cachedPublicAlbumJWTToken[collectionID] = token;
}
Future<Collection> _fromRemoteCollection(
Map<String, dynamic>? collectionData,
) async {

View File

@ -1,4 +1,5 @@
import 'dart:async';
import "dart:convert";
import "dart:io";
import 'package:flutter/material.dart';
@ -24,7 +25,10 @@ import 'package:photos/events/tab_changed_event.dart';
import 'package:photos/events/trigger_logout_event.dart';
import 'package:photos/events/user_logged_out_event.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/models/collection/collection.dart";
import 'package:photos/models/collection/collection_items.dart';
import "package:photos/models/file/file.dart";
// import "package:photos/models/file/file.dart";
import 'package:photos/models/selected_files.dart';
import 'package:photos/services/app_lifecycle_service.dart';
import 'package:photos/services/collections_service.dart';
@ -54,7 +58,9 @@ import "package:photos/ui/tabs/user_collections_tab.dart";
import "package:photos/ui/viewer/gallery/collection_page.dart";
import "package:photos/ui/viewer/search/search_widget.dart";
import 'package:photos/ui/viewer/search_tab/search_tab.dart';
import "package:photos/utils/crypto_util.dart";
import 'package:photos/utils/dialog_util.dart';
import "package:photos/utils/diff_fetcher.dart";
import "package:photos/utils/navigation_util.dart";
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:uni_links/uni_links.dart';
@ -100,6 +106,7 @@ class _HomeWidgetState extends State<HomeWidget> {
late StreamSubscription<BackupFoldersUpdatedEvent> _backupFoldersUpdatedEvent;
late StreamSubscription<AccountConfiguredEvent> _accountConfiguredEvent;
late StreamSubscription<CollectionUpdatedEvent> _collectionUpdatedEvent;
final DiffFetcher _diffFetcher = DiffFetcher();
@override
void initState() {
@ -224,6 +231,89 @@ class _HomeWidgetState extends State<HomeWidget> {
NotificationService.instance
.initialize(_onDidReceiveNotificationResponse)
.ignore();
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _initDeeplinkPublicAlbum();
});
}
Future<void> _initDeeplinkPublicAlbum() async {
// Handle the terminated state
final Uri? uri = await getInitialUri();
if (uri != null) {
await _handlePublicAlbumLink(uri);
}
// Handle the background state
uriLinkStream.listen((Uri? uri) async {
if (uri != null) {
await _handlePublicAlbumLink(uri);
}
});
}
Future<void> _handlePublicAlbumLink(Uri uri) async {
bool result = true;
final Collection collection =
await CollectionsService.instance.getPublicCollection(uri);
final publicUrl = collection.publicURLs![0];
if (publicUrl!.passwordEnabled) {
await showTextInputDialog(
context,
title: S.of(context).enterPassword,
submitButtonLabel: S.of(context).ok,
alwaysShowSuccessState: false,
onSubmit: (String text) async {
if (text.trim() == "") {
return;
}
try {
final hashedPassword = await CryptoUtil.deriveKey(
utf8.encode(text) as Uint8List,
CryptoUtil.base642bin(publicUrl.nonce!),
publicUrl.memLimit!,
publicUrl.opsLimit!,
);
_logger.info(
"Hashed password: ${CryptoUtil.bin2base64(hashedPassword)}",
);
result = await CollectionsService.instance
.verifyPublicCollectionPassword(
CryptoUtil.bin2base64(hashedPassword),
collection.id,
);
if (!result) {
await showErrorDialog(
context,
"Incorrect Password",
"Plesae try again",
);
}
} catch (e, s) {
_logger.severe("Failed to decrypt password for album", e, s);
rethrow;
}
},
);
}
if (result) {
final List<EnteFile> sharedFiles =
await _diffFetcher.getPublicFiles(context, collection.id);
await routeToPage(
context,
CollectionPage(
isFromPublicShareLink: true,
sharedLinkFiles: sharedFiles,
CollectionWithThumbnail(
collection,
null,
),
),
);
}
}
Future<void> _autoLogoutAlert() async {

View File

@ -24,6 +24,8 @@ class CollectionPage extends StatelessWidget {
final String tagPrefix;
final bool? hasVerifiedLock;
final bool isFromCollectPhotos;
final bool isFromPublicShareLink;
final List<EnteFile> sharedLinkFiles;
CollectionPage(
this.c, {
@ -31,6 +33,8 @@ class CollectionPage extends StatelessWidget {
this.hasVerifiedLock = false,
this.isFromCollectPhotos = false,
Key? key,
this.isFromPublicShareLink = false,
this.sharedLinkFiles = const [],
}) : super(key: key);
final _selectedFiles = SelectedFiles();
@ -49,6 +53,9 @@ class CollectionPage extends StatelessWidget {
c.thumbnail != null ? [c.thumbnail!] : null;
final gallery = Gallery(
asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) async {
if (isFromPublicShareLink) {
return FileLoadResult(sharedLinkFiles, false);
}
final FileLoadResult result =
await FilesDB.instance.getFilesInCollection(
c.collection.id,
@ -107,6 +114,8 @@ class CollectionPage extends StatelessWidget {
_selectedFiles,
collection: c.collection,
isFromCollectPhotos: isFromCollectPhotos,
isFromPublicShareLink: isFromPublicShareLink,
files: sharedLinkFiles,
),
),
bottomNavigationBar: isFromCollectPhotos

View File

@ -17,6 +17,7 @@ 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';
import "package:photos/models/file/file.dart";
import 'package:photos/models/gallery_type.dart';
import "package:photos/models/metadata/common_keys.dart";
import 'package:photos/models/selected_files.dart';
@ -41,6 +42,7 @@ import "package:photos/ui/viewer/gallery/hooks/add_photos_sheet.dart";
import 'package:photos/ui/viewer/gallery/hooks/pick_cover_photo.dart';
import 'package:photos/utils/data_util.dart';
import 'package:photos/utils/dialog_util.dart';
import "package:photos/utils/file_download_util.dart";
import 'package:photos/utils/magic_util.dart';
import 'package:photos/utils/navigation_util.dart';
import 'package:photos/utils/toast_util.dart';
@ -53,16 +55,20 @@ class GalleryAppBarWidget extends StatefulWidget {
final DeviceCollection? deviceCollection;
final Collection? collection;
final bool isFromCollectPhotos;
final bool isFromPublicShareLink;
final List<EnteFile> files;
const GalleryAppBarWidget(
this.type,
this.title,
this.selectedFiles, {
Key? key,
super.key,
this.deviceCollection,
this.collection,
this.isFromCollectPhotos = false,
}) : super(key: key);
this.isFromPublicShareLink = false,
this.files = const [],
});
@override
State<GalleryAppBarWidget> createState() => _GalleryAppBarWidgetState();
@ -84,6 +90,7 @@ enum AlbumPopupAction {
pinAlbum,
removeLink,
cleanUncategorized,
downloadAlbum,
}
class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
@ -457,6 +464,17 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
),
],
);
if (widget.isFromPublicShareLink) {
actions.clear();
items.clear();
items.add(
EntePopupMenuItem(
"Download album",
value: AlbumPopupAction.downloadAlbum,
icon: Icons.download_outlined,
),
);
}
if (items.isNotEmpty) {
actions.add(
PopupMenuButton(
@ -512,6 +530,8 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
await showOnMap();
} else if (value == AlbumPopupAction.cleanUncategorized) {
await onCleanUncategorizedClick(context);
} else if (value == AlbumPopupAction.downloadAlbum) {
await _downloadPublicAlbumToGallery(widget.files);
} else {
showToast(context, S.of(context).somethingWentWrong);
}
@ -523,6 +543,18 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
return actions;
}
Future<void> _downloadPublicAlbumToGallery(List<EnteFile> files) async {
final dialog = createProgressDialog(context, "Downloading album");
await dialog.show();
try {
await downloadPublicAlbumToGallery(files);
} catch (e, s) {
_logger.severe("Failed to download album", e, s);
await showGenericErrorDialog(context: context, error: e);
}
await dialog.hide();
}
Future<void> onCleanUncategorizedClick(BuildContext buildContext) async {
final actionResult = await showChoiceActionSheet(
context,

View File

@ -1,11 +1,14 @@
import 'dart:convert';
import 'dart:math';
import "package:dio/dio.dart";
import "package:flutter/material.dart";
import 'package:logging/logging.dart';
import 'package:photos/core/network/network.dart';
import 'package:photos/db/files_db.dart';
import 'package:photos/models/file/file.dart';
import "package:photos/models/metadata/file_magic.dart";
import "package:photos/services/collections_service.dart";
import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/file_download_util.dart';
@ -13,6 +16,79 @@ class DiffFetcher {
final _logger = Logger("DiffFetcher");
final _enteDio = NetworkClient.instance.enteDio;
Future<List<EnteFile>> getPublicFiles(
BuildContext context,
int collectionID,
) async {
try {
final authToken =
await CollectionsService.instance.getPublicAlbumToken(collectionID);
final authJWTToken = await CollectionsService.instance
.getPublicAlbumTokenJWT(collectionID);
final time =
CollectionsService.instance.getCollectionSyncTime(collectionID);
final headers = {
"X-Auth-Access-Token": authToken,
"Cache-Control": "no-cache",
if (authJWTToken != null) "X-Auth-Access-Token-JWT": authJWTToken,
};
final response = await _enteDio.get(
"/public-collection/diff",
options: Options(headers: headers),
queryParameters: {"sinceTime": time},
);
final diff = response.data["diff"] as List;
final startTime = DateTime.now();
final sharedFiles = <EnteFile>[];
for (final item in diff) {
final file = EnteFile();
file.uploadedFileID = item["id"];
file.collectionID = item["collectionID"];
file.ownerID = item["ownerID"];
file.encryptedKey = item["encryptedKey"];
file.keyDecryptionNonce = item["keyDecryptionNonce"];
file.fileDecryptionHeader = item["file"]["decryptionHeader"];
file.thumbnailDecryptionHeader = item["thumbnail"]["decryptionHeader"];
file.metadataDecryptionHeader = item["metadata"]["decryptionHeader"];
if (item["info"] != null) {
file.fileSize = item["info"]["fileSize"];
}
final fileKey = getFileKey(file);
final encodedMetadata = await CryptoUtil.decryptChaCha(
CryptoUtil.base642bin(item["metadata"]["encryptedData"]),
fileKey,
CryptoUtil.base642bin(file.metadataDecryptionHeader!),
);
final Map<String, dynamic> metadata =
jsonDecode(utf8.decode(encodedMetadata));
file.applyMetadata(metadata);
if (item['pubMagicMetadata'] != null) {
final utfEncodedMmd = await CryptoUtil.decryptChaCha(
CryptoUtil.base642bin(item['pubMagicMetadata']['data']),
fileKey,
CryptoUtil.base642bin(item['pubMagicMetadata']['header']),
);
file.pubMmdEncodedJson = utf8.decode(utfEncodedMmd);
file.pubMmdVersion = item['pubMagicMetadata']['version'];
file.pubMagicMetadata =
PubMagicMetadata.fromEncodedJson(file.pubMmdEncodedJson!);
}
sharedFiles.add(file);
}
_logger.info('[Collection-$collectionID] parsed ${diff.length} '
'diff items ( ${sharedFiles.length} updated) in ${DateTime.now().difference(startTime).inMilliseconds}ms');
return sharedFiles;
} catch (e, s) {
_logger.severe("Failed to decrypt collection $e", s);
rethrow;
}
}
Future<Diff> getEncryptedFilesDiff(int collectionID, int sinceTime) async {
try {
final response = await _enteDio.get(

View File

@ -24,14 +24,96 @@ import "package:photos/utils/file_util.dart";
final _logger = Logger("file_download_util");
Future<File?> downloadAndDecryptPublicFile(
EnteFile file,
String authToken, {
ProgressCallback? progressCallback,
}) async {
final String logPrefix = 'Public File-${file.uploadedFileID}:';
_logger
.info('$logPrefix starting download ${formatBytes(file.fileSize ?? 0)}');
_logger.severe("File id ${file.uploadedFileID}");
final String tempDir = Configuration.instance.getTempDirectory();
final String encryptedFilePath = "$tempDir${file.uploadedFileID}.encrypted";
final String decryptedFilePath = "$tempDir${file.uploadedFileID}.decrypted";
try {
final authJWTToken = await CollectionsService.instance
.getPublicAlbumTokenJWT(file.collectionID!);
final headers = {
"X-Auth-Access-Token": authToken,
if (authJWTToken != null) "X-Auth-Access-Token-JWT": authJWTToken,
};
final response = (await NetworkClient.instance.getDio().download(
"https://public-albums.ente.io/download/?fileID=${file.uploadedFileID}",
encryptedFilePath,
options: Options(
headers: headers,
responseType: ResponseType.bytes,
),
onReceiveProgress: (a, b) {
progressCallback?.call(a, b);
},
));
if (response.statusCode != 200) {
_logger.warning('$logPrefix download failed ${response.toString()}');
return null;
}
final int sizeInBytes = file.fileSize!;
final FakePeriodicProgress? fakeProgress = file.fileType == FileType.video
? FakePeriodicProgress(
callback: (count) {
progressCallback?.call(sizeInBytes, sizeInBytes);
},
duration: const Duration(milliseconds: 5000),
)
: null;
try {
fakeProgress?.start();
await CryptoUtil.decryptFile(
encryptedFilePath,
decryptedFilePath,
CryptoUtil.base642bin(file.fileDecryptionHeader!),
getFileKey(file),
);
fakeProgress?.stop();
_logger.info('$logPrefix file saved at $decryptedFilePath');
} catch (e, s) {
fakeProgress?.stop();
_logger.severe("Critical: $logPrefix failed to decrypt", e, s);
return null;
}
return File(decryptedFilePath);
} catch (e, s) {
_logger.severe("$logPrefix failed to download", e, s);
return null;
}
}
Future<File?> downloadAndDecrypt(
EnteFile file, {
ProgressCallback? progressCallback,
}) async {
final authToken =
await CollectionsService.instance.getPublicAlbumToken(file.collectionID!);
if (authToken != null) {
final authToken = await CollectionsService.instance
.getPublicAlbumToken(file.collectionID!);
return await downloadAndDecryptPublicFile(
file,
authToken!,
progressCallback: progressCallback,
);
}
final String logPrefix = 'File-${file.uploadedFileID}:';
_logger
.info('$logPrefix starting download ${formatBytes(file.fileSize ?? 0)}');
final String tempDir = Configuration.instance.getTempDirectory();
final String encryptedFilePath = "$tempDir${file.generatedID}.encrypted";
final encryptedFile = File(encryptedFilePath);
@ -125,6 +207,12 @@ Future<Uint8List> getFileKeyUsingBgWorker(EnteFile file) async {
);
}
Future<void> downloadPublicAlbumToGallery(List<EnteFile> files) async {
for (final file in files) {
await downloadToGallery(file);
}
}
Future<void> downloadToGallery(EnteFile file) async {
try {
final FileType type = file.fileType;

View File

@ -12,6 +12,7 @@ import 'package:photos/core/constants.dart';
import 'package:photos/core/errors.dart';
import 'package:photos/core/network/network.dart';
import 'package:photos/models/file/file.dart';
import "package:photos/services/collections_service.dart";
import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/file_download_util.dart';
import 'package:photos/utils/file_uploader_util.dart';
@ -160,15 +161,36 @@ Future<void> _downloadAndDecryptThumbnail(FileDownloadItem item) async {
final file = item.file;
Uint8List encryptedThumbnail;
try {
encryptedThumbnail = (await NetworkClient.instance.getDio().get(
file.thumbnailUrl,
options: Options(
headers: {"X-Auth-Token": Configuration.instance.getToken()},
responseType: ResponseType.bytes,
),
cancelToken: item.cancelToken,
))
.data;
final authToken = await CollectionsService.instance
.getPublicAlbumToken(file.collectionID!);
if (authToken != null) {
final authJWTToken = await CollectionsService.instance
.getPublicAlbumTokenJWT(file.collectionID!);
final headers = {
"X-Auth-Access-Token": authToken,
if (authJWTToken != null) "X-Auth-Access-Token-JWT": authJWTToken,
};
encryptedThumbnail = (await NetworkClient.instance.getDio().get(
"https://public-albums.ente.io/preview/?fileID=${file.uploadedFileID}",
options: Options(
headers: headers,
responseType: ResponseType.bytes,
),
))
.data;
} else {
encryptedThumbnail = (await NetworkClient.instance.getDio().get(
file.thumbnailUrl,
options: Options(
headers: {"X-Auth-Token": Configuration.instance.getToken()},
responseType: ResponseType.bytes,
),
cancelToken: item.cancelToken,
))
.data;
}
} catch (e) {
if (e is DioError && CancelToken.isCancel(e)) {
return;