manage_links_widget.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import 'dart:convert';
  2. import 'dart:typed_data';
  3. import 'package:collection/collection.dart';
  4. import 'package:flutter/cupertino.dart';
  5. import 'package:flutter/material.dart';
  6. import 'package:flutter_sodium/flutter_sodium.dart';
  7. import 'package:photos/models/collection.dart';
  8. import 'package:photos/services/collections_service.dart';
  9. import 'package:photos/theme/colors.dart';
  10. import 'package:photos/theme/ente_theme.dart';
  11. import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
  12. import 'package:photos/ui/components/captioned_text_widget.dart';
  13. import 'package:photos/ui/components/divider_widget.dart';
  14. import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
  15. import 'package:photos/ui/components/menu_section_description_widget.dart';
  16. import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart';
  17. import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart';
  18. import 'package:photos/utils/crypto_util.dart';
  19. import 'package:photos/utils/date_time_util.dart';
  20. import 'package:photos/utils/dialog_util.dart';
  21. import 'package:photos/utils/navigation_util.dart';
  22. import 'package:photos/utils/toast_util.dart';
  23. class ManageSharedLinkWidget extends StatefulWidget {
  24. final Collection? collection;
  25. const ManageSharedLinkWidget({Key? key, this.collection}) : super(key: key);
  26. @override
  27. State<ManageSharedLinkWidget> createState() => _ManageSharedLinkWidgetState();
  28. }
  29. class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
  30. final CollectionActions sharingActions =
  31. CollectionActions(CollectionsService.instance);
  32. @override
  33. void initState() {
  34. super.initState();
  35. }
  36. @override
  37. Widget build(BuildContext context) {
  38. final isCollectEnabled =
  39. widget.collection!.publicURLs?.firstOrNull?.enableCollect ?? false;
  40. final isDownloadEnabled =
  41. widget.collection!.publicURLs?.firstOrNull?.enableDownload ?? true;
  42. final isPasswordEnabled =
  43. widget.collection!.publicURLs?.firstOrNull?.passwordEnabled ?? false;
  44. final enteColorScheme = getEnteColorScheme(context);
  45. final PublicURL url = widget.collection!.publicURLs!.firstOrNull!;
  46. return Scaffold(
  47. appBar: AppBar(
  48. elevation: 0,
  49. title: const Text(
  50. "Manage link",
  51. ),
  52. ),
  53. body: SingleChildScrollView(
  54. child: ListBody(
  55. children: <Widget>[
  56. Padding(
  57. padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
  58. child: Column(
  59. crossAxisAlignment: CrossAxisAlignment.start,
  60. children: [
  61. MenuItemWidget(
  62. key: ValueKey("Allow collect $isCollectEnabled"),
  63. captionedTextWidget: const CaptionedTextWidget(
  64. title: "Allow adding photos",
  65. ),
  66. alignCaptionedTextToLeft: true,
  67. menuItemColor: getEnteColorScheme(context).fillFaint,
  68. trailingWidget: Switch.adaptive(
  69. value: widget.collection!.publicURLs?.firstOrNull
  70. ?.enableCollect ??
  71. false,
  72. onChanged: (value) async {
  73. await _updateUrlSettings(
  74. context,
  75. {'enableCollect': value},
  76. );
  77. },
  78. ),
  79. ),
  80. const MenuSectionDescriptionWidget(
  81. content:
  82. "Allow people with the link to also add photos to the shared "
  83. "album.",
  84. ),
  85. const SizedBox(height: 24),
  86. MenuItemWidget(
  87. alignCaptionedTextToLeft: true,
  88. captionedTextWidget: CaptionedTextWidget(
  89. title: "Link expiry",
  90. subTitle: (url.hasExpiry
  91. ? (url.isExpired ? "Expired" : "Enabled")
  92. : "Never"),
  93. subTitleColor: url.isExpired ? warning500 : null,
  94. ),
  95. trailingIcon: Icons.chevron_right,
  96. menuItemColor: enteColorScheme.fillFaint,
  97. surfaceExecutionStates: false,
  98. onTap: () async {
  99. routeToPage(
  100. context,
  101. LinkExpiryPickerPage(widget.collection!),
  102. ).then((value) {
  103. setState(() {});
  104. });
  105. },
  106. ),
  107. url.hasExpiry
  108. ? MenuSectionDescriptionWidget(
  109. content: url.isExpired
  110. ? "This link has expired. Please select a new expiry time or disable link expiry."
  111. : 'Link will expire on '
  112. '${getFormattedTime(DateTime.fromMicrosecondsSinceEpoch(url.validTill))}',
  113. )
  114. : const SizedBox.shrink(),
  115. const Padding(padding: EdgeInsets.only(top: 24)),
  116. MenuItemWidget(
  117. captionedTextWidget: CaptionedTextWidget(
  118. title: "Device limit",
  119. subTitle: widget
  120. .collection!.publicURLs!.first!.deviceLimit
  121. .toString(),
  122. ),
  123. trailingIcon: Icons.chevron_right,
  124. menuItemColor: enteColorScheme.fillFaint,
  125. alignCaptionedTextToLeft: true,
  126. isBottomBorderRadiusRemoved: true,
  127. onTap: () async {
  128. routeToPage(
  129. context,
  130. DeviceLimitPickerPage(widget.collection!),
  131. ).then((value) {
  132. setState(() {});
  133. });
  134. },
  135. surfaceExecutionStates: false,
  136. ),
  137. DividerWidget(
  138. dividerType: DividerType.menuNoIcon,
  139. bgColor: getEnteColorScheme(context).fillFaint,
  140. ),
  141. MenuItemWidget(
  142. key: ValueKey("Allow downloads $isDownloadEnabled"),
  143. captionedTextWidget: const CaptionedTextWidget(
  144. title: "Allow downloads",
  145. ),
  146. alignCaptionedTextToLeft: true,
  147. isBottomBorderRadiusRemoved: true,
  148. isTopBorderRadiusRemoved: true,
  149. menuItemColor: getEnteColorScheme(context).fillFaint,
  150. trailingWidget: Switch.adaptive(
  151. value: isDownloadEnabled,
  152. onChanged: (value) async {
  153. await _updateUrlSettings(
  154. context,
  155. {'enableDownload': value},
  156. );
  157. if (!value) {
  158. showErrorDialog(
  159. context,
  160. "Please note",
  161. "Viewers can still take screenshots or save a copy of your photos using external tools",
  162. );
  163. }
  164. },
  165. ),
  166. ),
  167. DividerWidget(
  168. dividerType: DividerType.menuNoIcon,
  169. bgColor: getEnteColorScheme(context).fillFaint,
  170. ),
  171. MenuItemWidget(
  172. key: ValueKey("Password lock $isPasswordEnabled"),
  173. captionedTextWidget: const CaptionedTextWidget(
  174. title: "Password lock",
  175. ),
  176. alignCaptionedTextToLeft: true,
  177. isTopBorderRadiusRemoved: true,
  178. menuItemColor: getEnteColorScheme(context).fillFaint,
  179. trailingWidget: Switch.adaptive(
  180. value: isPasswordEnabled,
  181. onChanged: (enablePassword) async {
  182. if (enablePassword) {
  183. showTextInputDialog(
  184. context,
  185. title: "Set a password",
  186. submitButtonLabel: "Lock",
  187. hintText: "Enter password",
  188. isPasswordInput: true,
  189. onSubmit: (String password) async {
  190. if (password.trim().isNotEmpty) {
  191. final propToUpdate =
  192. await _getEncryptedPassword(
  193. password,
  194. );
  195. await _updateUrlSettings(
  196. context,
  197. propToUpdate,
  198. showProgressDialog: false,
  199. );
  200. }
  201. },
  202. );
  203. } else {
  204. await _updateUrlSettings(
  205. context,
  206. {'disablePassword': true},
  207. );
  208. }
  209. },
  210. ),
  211. ),
  212. const SizedBox(
  213. height: 24,
  214. ),
  215. MenuItemWidget(
  216. captionedTextWidget: const CaptionedTextWidget(
  217. title: "Remove link",
  218. textColor: warning500,
  219. makeTextBold: true,
  220. ),
  221. leadingIcon: Icons.remove_circle_outline,
  222. leadingIconColor: warning500,
  223. menuItemColor: getEnteColorScheme(context).fillFaint,
  224. surfaceExecutionStates: false,
  225. onTap: () async {
  226. final bool result = await sharingActions.disableUrl(
  227. context,
  228. widget.collection!,
  229. );
  230. if (result && mounted) {
  231. Navigator.of(context).pop();
  232. }
  233. },
  234. ),
  235. ],
  236. ),
  237. ),
  238. ],
  239. ),
  240. ),
  241. );
  242. }
  243. Future<Map<String, dynamic>> _getEncryptedPassword(String pass) async {
  244. assert(
  245. Sodium.cryptoPwhashAlgArgon2id13 == Sodium.cryptoPwhashAlgDefault,
  246. "mismatch in expected default pw hashing algo",
  247. );
  248. final int memLimit = Sodium.cryptoPwhashMemlimitInteractive;
  249. final int opsLimit = Sodium.cryptoPwhashOpslimitInteractive;
  250. final kekSalt = CryptoUtil.getSaltToDeriveKey();
  251. final result = await CryptoUtil.deriveKey(
  252. utf8.encode(pass) as Uint8List,
  253. kekSalt,
  254. memLimit,
  255. opsLimit,
  256. );
  257. return {
  258. 'passHash': Sodium.bin2base64(result),
  259. 'nonce': Sodium.bin2base64(kekSalt),
  260. 'memLimit': memLimit,
  261. 'opsLimit': opsLimit,
  262. };
  263. }
  264. Future<void> _updateUrlSettings(
  265. BuildContext context, Map<String, dynamic> prop,
  266. {bool showProgressDialog = true}) async {
  267. final dialog = showProgressDialog
  268. ? createProgressDialog(context, "Please wait...")
  269. : null;
  270. await dialog?.show();
  271. try {
  272. await CollectionsService.instance
  273. .updateShareUrl(widget.collection!, prop);
  274. await dialog?.hide();
  275. showShortToast(context, "Album updated");
  276. if (mounted) {
  277. setState(() {});
  278. }
  279. } catch (e) {
  280. await dialog?.hide();
  281. await showGenericErrorDialog(context: context);
  282. rethrow;
  283. }
  284. }
  285. }