manage_links_widget.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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. alwaysShowSuccessState: true,
  190. onSubmit: (String password) async {
  191. if (password.trim().isNotEmpty) {
  192. final propToUpdate =
  193. await _getEncryptedPassword(
  194. password,
  195. );
  196. await _updateUrlSettings(
  197. context,
  198. propToUpdate,
  199. showProgressDialog: false,
  200. );
  201. }
  202. },
  203. );
  204. } else {
  205. await _updateUrlSettings(
  206. context,
  207. {'disablePassword': true},
  208. );
  209. }
  210. },
  211. ),
  212. ),
  213. const SizedBox(
  214. height: 24,
  215. ),
  216. MenuItemWidget(
  217. captionedTextWidget: const CaptionedTextWidget(
  218. title: "Remove link",
  219. textColor: warning500,
  220. makeTextBold: true,
  221. ),
  222. leadingIcon: Icons.remove_circle_outline,
  223. leadingIconColor: warning500,
  224. menuItemColor: getEnteColorScheme(context).fillFaint,
  225. surfaceExecutionStates: false,
  226. onTap: () async {
  227. final bool result = await sharingActions.disableUrl(
  228. context,
  229. widget.collection!,
  230. );
  231. if (result && mounted) {
  232. Navigator.of(context).pop();
  233. }
  234. },
  235. ),
  236. ],
  237. ),
  238. ),
  239. ],
  240. ),
  241. ),
  242. );
  243. }
  244. Future<Map<String, dynamic>> _getEncryptedPassword(String pass) async {
  245. assert(
  246. Sodium.cryptoPwhashAlgArgon2id13 == Sodium.cryptoPwhashAlgDefault,
  247. "mismatch in expected default pw hashing algo",
  248. );
  249. final int memLimit = Sodium.cryptoPwhashMemlimitInteractive;
  250. final int opsLimit = Sodium.cryptoPwhashOpslimitInteractive;
  251. final kekSalt = CryptoUtil.getSaltToDeriveKey();
  252. final result = await CryptoUtil.deriveKey(
  253. utf8.encode(pass) as Uint8List,
  254. kekSalt,
  255. memLimit,
  256. opsLimit,
  257. );
  258. return {
  259. 'passHash': Sodium.bin2base64(result),
  260. 'nonce': Sodium.bin2base64(kekSalt),
  261. 'memLimit': memLimit,
  262. 'opsLimit': opsLimit,
  263. };
  264. }
  265. Future<void> _updateUrlSettings(
  266. BuildContext context, Map<String, dynamic> prop,
  267. {bool showProgressDialog = true}) async {
  268. final dialog = showProgressDialog
  269. ? createProgressDialog(context, "Please wait...")
  270. : null;
  271. await dialog?.show();
  272. try {
  273. await CollectionsService.instance
  274. .updateShareUrl(widget.collection!, prop);
  275. await dialog?.hide();
  276. showShortToast(context, "Album updated");
  277. if (mounted) {
  278. setState(() {});
  279. }
  280. } catch (e) {
  281. await dialog?.hide();
  282. await showGenericErrorDialog(context: context);
  283. rethrow;
  284. }
  285. }
  286. }