manage_links_widget.dart 13 KB

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