manage_links_widget.dart 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  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. setState(() {});
  78. },
  79. ),
  80. ),
  81. const MenuSectionDescriptionWidget(
  82. content:
  83. "Allow people with the link to also add photos to the shared "
  84. "album.",
  85. ),
  86. const SizedBox(height: 24),
  87. MenuItemWidget(
  88. alignCaptionedTextToLeft: true,
  89. captionedTextWidget: CaptionedTextWidget(
  90. title: "Link expiry",
  91. subTitle: (url.hasExpiry
  92. ? (url.isExpired ? "Expired" : "Enabled")
  93. : "Never"),
  94. subTitleColor: url.isExpired ? warning500 : null,
  95. ),
  96. trailingIcon: Icons.chevron_right,
  97. menuItemColor: enteColorScheme.fillFaint,
  98. surfaceExecutionStates: false,
  99. onTap: () async {
  100. routeToPage(
  101. context,
  102. LinkExpiryPickerPage(widget.collection!),
  103. ).then((value) {
  104. setState(() {});
  105. });
  106. },
  107. ),
  108. url.hasExpiry
  109. ? MenuSectionDescriptionWidget(
  110. content: url.isExpired
  111. ? "This link has expired. Please select a new expiry time or disable link expiry."
  112. : 'Link will expire on '
  113. '${getFormattedTime(DateTime.fromMicrosecondsSinceEpoch(url.validTill))}',
  114. )
  115. : const SizedBox.shrink(),
  116. const Padding(padding: EdgeInsets.only(top: 24)),
  117. MenuItemWidget(
  118. captionedTextWidget: CaptionedTextWidget(
  119. title: "Device limit",
  120. subTitle: widget
  121. .collection!.publicURLs!.first!.deviceLimit
  122. .toString(),
  123. ),
  124. trailingIcon: Icons.chevron_right,
  125. menuItemColor: enteColorScheme.fillFaint,
  126. alignCaptionedTextToLeft: true,
  127. isBottomBorderRadiusRemoved: true,
  128. onTap: () async {
  129. routeToPage(
  130. context,
  131. DeviceLimitPickerPage(widget.collection!),
  132. ).then((value) {
  133. setState(() {});
  134. });
  135. },
  136. surfaceExecutionStates: false,
  137. ),
  138. DividerWidget(
  139. dividerType: DividerType.menuNoIcon,
  140. bgColor: getEnteColorScheme(context).fillFaint,
  141. ),
  142. MenuItemWidget(
  143. key: ValueKey("Allow downloads $isDownloadEnabled"),
  144. captionedTextWidget: const CaptionedTextWidget(
  145. title: "Allow downloads",
  146. ),
  147. alignCaptionedTextToLeft: true,
  148. isBottomBorderRadiusRemoved: true,
  149. isTopBorderRadiusRemoved: true,
  150. menuItemColor: getEnteColorScheme(context).fillFaint,
  151. trailingWidget: Switch.adaptive(
  152. value: isDownloadEnabled,
  153. onChanged: (value) async {
  154. await _updateUrlSettings(
  155. context,
  156. {'enableDownload': value},
  157. );
  158. if (!value) {
  159. showErrorDialog(
  160. context,
  161. "Please note",
  162. "Viewers can still take screenshots or save a copy of your photos using external tools",
  163. );
  164. }
  165. setState(() {});
  166. },
  167. ),
  168. ),
  169. DividerWidget(
  170. dividerType: DividerType.menuNoIcon,
  171. bgColor: getEnteColorScheme(context).fillFaint,
  172. ),
  173. MenuItemWidget(
  174. key: ValueKey("Password lock $isPasswordEnabled"),
  175. captionedTextWidget: const CaptionedTextWidget(
  176. title: "Password lock",
  177. ),
  178. alignCaptionedTextToLeft: true,
  179. isTopBorderRadiusRemoved: true,
  180. menuItemColor: getEnteColorScheme(context).fillFaint,
  181. trailingWidget: Switch.adaptive(
  182. value: isPasswordEnabled,
  183. onChanged: (enablePassword) async {
  184. if (enablePassword) {
  185. final inputResult =
  186. await _displayLinkPasswordInput(context);
  187. if (inputResult != null &&
  188. inputResult == 'ok' &&
  189. _textFieldController.text.trim().isNotEmpty) {
  190. final propToUpdate = await _getEncryptedPassword(
  191. _textFieldController.text,
  192. );
  193. await _updateUrlSettings(context, propToUpdate);
  194. }
  195. } else {
  196. await _updateUrlSettings(
  197. context,
  198. {'disablePassword': true},
  199. );
  200. }
  201. setState(() {});
  202. },
  203. ),
  204. ),
  205. const SizedBox(
  206. height: 24,
  207. ),
  208. MenuItemWidget(
  209. captionedTextWidget: const CaptionedTextWidget(
  210. title: "Remove link",
  211. textColor: warning500,
  212. makeTextBold: true,
  213. ),
  214. leadingIcon: Icons.remove_circle_outline,
  215. leadingIconColor: warning500,
  216. menuItemColor: getEnteColorScheme(context).fillFaint,
  217. surfaceExecutionStates: false,
  218. onTap: () async {
  219. final bool result = await sharingActions.disableUrl(
  220. context,
  221. widget.collection!,
  222. );
  223. if (result && mounted) {
  224. Navigator.of(context).pop();
  225. }
  226. },
  227. ),
  228. ],
  229. ),
  230. ),
  231. ],
  232. ),
  233. ),
  234. );
  235. }
  236. final TextEditingController _textFieldController = TextEditingController();
  237. Future<String?> _displayLinkPasswordInput(BuildContext context) async {
  238. _textFieldController.clear();
  239. return showDialog<String>(
  240. context: context,
  241. builder: (context) {
  242. bool passwordVisible = false;
  243. return StatefulBuilder(
  244. builder: (context, setState) {
  245. return AlertDialog(
  246. title: const Text('Enter password'),
  247. content: TextFormField(
  248. autofillHints: const [AutofillHints.newPassword],
  249. decoration: InputDecoration(
  250. hintText: "Password",
  251. contentPadding: const EdgeInsets.all(12),
  252. suffixIcon: IconButton(
  253. icon: Icon(
  254. passwordVisible ? Icons.visibility : Icons.visibility_off,
  255. color: Colors.white.withOpacity(0.5),
  256. size: 20,
  257. ),
  258. onPressed: () {
  259. passwordVisible = !passwordVisible;
  260. setState(() {});
  261. },
  262. ),
  263. ),
  264. obscureText: !passwordVisible,
  265. controller: _textFieldController,
  266. autofocus: true,
  267. autocorrect: false,
  268. keyboardType: TextInputType.visiblePassword,
  269. onChanged: (_) {
  270. setState(() {});
  271. },
  272. ),
  273. actions: <Widget>[
  274. TextButton(
  275. child: Text(
  276. 'Cancel',
  277. style: Theme.of(context).textTheme.subtitle2,
  278. ),
  279. onPressed: () {
  280. Navigator.pop(context, 'cancel');
  281. },
  282. ),
  283. TextButton(
  284. child:
  285. Text('Ok', style: Theme.of(context).textTheme.subtitle2),
  286. onPressed: () {
  287. if (_textFieldController.text.trim().isEmpty) {
  288. return;
  289. }
  290. Navigator.pop(context, 'ok');
  291. },
  292. ),
  293. ],
  294. );
  295. },
  296. );
  297. },
  298. );
  299. }
  300. Future<Map<String, dynamic>> _getEncryptedPassword(String pass) async {
  301. assert(
  302. Sodium.cryptoPwhashAlgArgon2id13 == Sodium.cryptoPwhashAlgDefault,
  303. "mismatch in expected default pw hashing algo",
  304. );
  305. final int memLimit = Sodium.cryptoPwhashMemlimitInteractive;
  306. final int opsLimit = Sodium.cryptoPwhashOpslimitInteractive;
  307. final kekSalt = CryptoUtil.getSaltToDeriveKey();
  308. final result = await CryptoUtil.deriveKey(
  309. utf8.encode(pass) as Uint8List,
  310. kekSalt,
  311. memLimit,
  312. opsLimit,
  313. );
  314. return {
  315. 'passHash': Sodium.bin2base64(result),
  316. 'nonce': Sodium.bin2base64(kekSalt),
  317. 'memLimit': memLimit,
  318. 'opsLimit': opsLimit,
  319. };
  320. }
  321. Future<void> _updateUrlSettings(
  322. BuildContext context,
  323. Map<String, dynamic> prop,
  324. ) async {
  325. final dialog = createProgressDialog(context, "Please wait...");
  326. await dialog.show();
  327. try {
  328. await CollectionsService.instance
  329. .updateShareUrl(widget.collection!, prop);
  330. await dialog.hide();
  331. showShortToast(context, "Album updated");
  332. } catch (e) {
  333. await dialog.hide();
  334. await showGenericErrorDialog(context: context);
  335. }
  336. }
  337. }