Merge pull request #193 from ente-io/link_sharing_v2

Support for setting link expiry, download button. & password
This commit is contained in:
Vishnu Mohandas 2022-02-26 20:20:25 +05:30 committed by GitHub
commit 6907dd2d3a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 677 additions and 53 deletions

View file

@ -317,18 +317,23 @@ class PublicURL {
String url;
int deviceLimit;
int validTill;
bool enableDownload = true;
bool passwordEnabled = false;
PublicURL({
this.url,
this.deviceLimit,
this.validTill,
});
PublicURL(
{this.url,
this.deviceLimit,
this.validTill,
this.enableDownload,
this.passwordEnabled});
Map<String, dynamic> toMap() {
return {
'url': url,
'deviceLimit': deviceLimit,
'validTill': validTill,
'enableDownload': enableDownload,
'passwordEnabled': passwordEnabled,
};
}
@ -338,7 +343,9 @@ class PublicURL {
return PublicURL(
url: map['url'],
deviceLimit: map['deviceLimit'],
validTill: map['validTill'],
validTill: map['validTill'] ?? 0,
enableDownload: map['enableDownload'] ?? true,
passwordEnabled: map['passwordEnabled'] ?? false,
);
}
@ -349,7 +356,7 @@ class PublicURL {
@override
String toString() =>
'PublicUrl( url: $url, deviceLimit: $deviceLimit, validTill: $validTill)';
'PublicUrl( url: $url, deviceLimit: $deviceLimit, validTill: $validTill, , enableDownload: $enableDownload, , passwordEnabled: $passwordEnabled)';
@override
bool operator ==(Object o) {
@ -358,9 +365,16 @@ class PublicURL {
return o is PublicURL &&
o.deviceLimit == deviceLimit &&
o.url == url &&
o.validTill == validTill;
o.validTill == validTill &&
o.enableDownload == enableDownload &&
o.passwordEnabled == passwordEnabled;
}
@override
int get hashCode => deviceLimit.hashCode ^ url.hashCode ^ validTill.hashCode;
int get hashCode =>
deviceLimit.hashCode ^
url.hashCode ^
validTill.hashCode ^
enableDownload.hashCode ^
passwordEnabled.hashCode;
}

View file

View file

@ -298,6 +298,33 @@ class CollectionsService {
}
}
Future<void> updateShareUrl(
Collection collection, Map<String, dynamic> prop) async {
prop.putIfAbsent('collectionID', () => collection.id);
try {
final response = await _dio.put(
Configuration.instance.getHttpEndpoint() + "/collections/share-url",
data: json.encode(prop),
options: Options(
headers: {"X-Auth-Token": Configuration.instance.getToken()}),
);
// remove existing url information
collection.publicURLs?.clear();
collection.publicURLs?.add(PublicURL.fromMap(response.data["result"]));
await _db.insert(List.from([collection]));
_cacheCollectionAttributes(collection);
Bus.instance.fire(CollectionUpdatedEvent(collection.id, <File>[]));
} on DioError catch (e) {
if (e.response.statusCode == 402) {
throw SharingNotPermittedForFreeAccountsError();
}
rethrow;
} catch (e, s) {
_logger.severe("failed to rename collection", e, s);
rethrow;
}
}
Future<void> disableShareUrl(Collection collection) async {
try {
await _dio.delete(

View file

@ -13,7 +13,9 @@ Future<T> showChoiceDialog<T>(
String title,
String content, {
String firstAction = 'ok',
Color firstActionColor,
String secondAction = 'cancel',
Color secondActionColor,
ActionType actionType = ActionType.confirm,
}) {
AlertDialog alert = AlertDialog(
@ -34,8 +36,8 @@ Future<T> showChoiceDialog<T>(
child: Text(
firstAction,
style: TextStyle(
color:
actionType == ActionType.critical ? Colors.red : Colors.white,
color: firstActionColor ??
(actionType == ActionType.critical ? Colors.red : Colors.white),
),
),
onPressed: () {
@ -47,7 +49,7 @@ Future<T> showChoiceDialog<T>(
child: Text(
secondAction,
style: TextStyle(
color: Theme.of(context).buttonColor,
color: secondActionColor ?? Theme.of(context).buttonColor,
),
),
onPressed: () {

View file

@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';
const kDatePickerTheme = DatePickerTheme(
backgroundColor: Colors.black,
itemStyle: TextStyle(
color: Colors.white,
),
cancelStyle: TextStyle(
color: Colors.white,
),
);

View file

@ -0,0 +1,534 @@
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';
import 'package:flutter_sodium/flutter_sodium.dart';
import 'package:photos/models/collection.dart';
import 'package:photos/services/collections_service.dart';
import 'package:photos/ui/common/dialogs.dart';
import 'package:photos/ui/common/widget_theme.dart';
import 'package:photos/utils/crypto_util.dart';
import 'package:photos/utils/date_time_util.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/toast_util.dart';
import 'package:tuple/tuple.dart';
class ManageSharedLinkWidget extends StatefulWidget {
final Collection collection;
ManageSharedLinkWidget({Key key, this.collection}) : super(key: key);
@override
_ManageSharedLinkWidgetState createState() => _ManageSharedLinkWidgetState();
}
class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
// index, title, milliseconds in future post which link should expire (when >0)
final List<Tuple3<int, String, int>> _expiryOptions = [
Tuple3(0, "never", 0),
Tuple3(1, "after 1 hour", Duration(hours: 1).inMicroseconds),
Tuple3(2, "after 1 day", Duration(days: 1).inMicroseconds),
Tuple3(3, "after 1 week", Duration(days: 7).inMicroseconds),
// todo: make this time calculation perfect
Tuple3(4, "after 1 month", Duration(days: 30).inMicroseconds),
Tuple3(5, "after 1 year", Duration(days: 365).inMicroseconds),
Tuple3(6, "custom", -1),
];
Tuple3<int, String, int> _selectedExpiry;
int _selectedDeviceLimitIndex = 0;
@override
void initState() {
_selectedExpiry = _expiryOptions.first;
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
"manage link",
style: TextStyle(
fontSize: 18,
),
),
),
body: SingleChildScrollView(
child: ListBody(
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
children: [
Padding(padding: EdgeInsets.all(4)),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () async {
await showPicker();
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("link expiry"),
Padding(padding: EdgeInsets.all(4)),
_getLinkExpiryTimeWidget(),
],
),
Icon(Icons.navigate_next),
],
),
),
Padding(padding: EdgeInsets.all(4)),
Divider(height: 4),
Padding(padding: EdgeInsets.all(4)),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
_showDeviceLimitPicker();
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("device limit"),
Padding(padding: EdgeInsets.all(4)),
Text(
widget.collection.publicURLs.first.deviceLimit
.toString(),
style: TextStyle(
color: Colors.grey,
),
),
],
),
Icon(Icons.navigate_next),
],
),
),
Padding(padding: EdgeInsets.all(4)),
Divider(height: 4),
Padding(padding: EdgeInsets.all(4)),
SizedBox(
height: 36,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("password lock"),
Switch.adaptive(
value: widget.collection.publicURLs?.first
?.passwordEnabled ??
false,
onChanged: (enablePassword) async {
if (enablePassword) {
var inputResult =
await _displayLinkPasswordInput(context);
if (inputResult != null &&
inputResult == 'ok' &&
_textFieldController.text.trim().isNotEmpty) {
var propToUpdate = await _getEncryptedPassword(
_textFieldController.text);
await _updateUrlSettings(context, propToUpdate);
}
} else {
await _updateUrlSettings(
context, {'disablePassword': true});
}
setState(() {});
},
),
],
),
),
Padding(padding: EdgeInsets.all(4)),
Divider(height: 4),
Padding(padding: EdgeInsets.all(4)),
SizedBox(
height: 36,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("file download"),
Switch.adaptive(
value: widget.collection.publicURLs?.first
?.enableDownload ??
true,
onChanged: (value) async {
if (!value) {
final choice = await showChoiceDialog(
context,
'disable downloads',
'are you sure that you want to disable the download button for files?',
firstAction: 'no',
secondAction: 'yes',
firstActionColor: Theme.of(context).buttonColor,
secondActionColor: Colors.white,
);
if (choice != DialogUserChoice.secondChoice) {
return;
}
}
await _updateUrlSettings(
context, {'enableDownload': value});
if (!value) {
showErrorDialog(context, "please note",
"viewers can still take screenshots or save a copy of your photos using external tools");
}
setState(() {});
},
),
],
),
),
],
),
),
],
),
),
);
}
Future<void> showPicker() async {
Widget getOptionText(String text) {
return Text(text, style: TextStyle(color: Colors.white));
}
return showCupertinoModalPopup(
context: context,
builder: (context) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
border: Border(
bottom: BorderSide(
color: Color(0xff999999),
width: 0.0,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
CupertinoButton(
child: Text('cancel',
style: TextStyle(
color: Colors.white,
)),
onPressed: () {
Navigator.of(context).pop('cancel');
},
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 5.0,
),
),
CupertinoButton(
child: Text('confirm',
style: TextStyle(
color: Colors.white,
)),
onPressed: () async {
int newValidTill = -1;
int expireAfterInMicroseconds = _selectedExpiry.item3;
// need to manually select time
if (expireAfterInMicroseconds < 0) {
var timeInMicrosecondsFromEpoch =
await _showDateTimePicker();
if (timeInMicrosecondsFromEpoch != null) {
newValidTill = timeInMicrosecondsFromEpoch;
}
} else if (expireAfterInMicroseconds == 0) {
// no expiry
newValidTill = 0;
} else {
newValidTill = DateTime.now().microsecondsSinceEpoch +
expireAfterInMicroseconds;
}
if (newValidTill >= 0) {
await _updateUrlSettings(
context, {'validTill': newValidTill});
setState(() {});
}
Navigator.of(context).pop('');
},
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 2.0,
),
)
],
),
),
Container(
height: 220.0,
color: Color(0xfff7f7f7),
child: CupertinoPicker(
backgroundColor: Colors.black.withOpacity(0.95),
children:
_expiryOptions.map((e) => getOptionText(e.item2)).toList(),
onSelectedItemChanged: (value) {
var firstWhere = _expiryOptions
.firstWhere((element) => element.item1 == value);
setState(() {
_selectedExpiry = firstWhere;
});
},
magnification: 1.3,
useMagnifier: true,
itemExtent: 25,
diameterRatio: 1,
),
)
],
);
},
);
}
// _showDateTimePicker return null if user doesn't select date-time
Future<int> _showDateTimePicker() async {
final dateResult = await DatePicker.showDatePicker(
context,
minTime: DateTime.now(),
currentTime: DateTime.now(),
locale: LocaleType.en,
theme: kDatePickerTheme,
);
if (dateResult == null) {
return null;
}
final dateWithTimeResult = await DatePicker.showTime12hPicker(
context,
showTitleActions: true,
currentTime: dateResult,
locale: LocaleType.en,
theme: kDatePickerTheme,
);
if (dateWithTimeResult == null) {
return null;
} else {
return dateWithTimeResult.microsecondsSinceEpoch;
}
}
final TextEditingController _textFieldController = TextEditingController();
Future<String> _displayLinkPasswordInput(BuildContext context) async {
_textFieldController.clear();
return showDialog<String>(
context: context,
builder: (context) {
bool _passwordVisible = false;
return StatefulBuilder(builder: (context, setState) {
return AlertDialog(
title: Text('enter password'),
content: TextFormField(
autofillHints: const [AutofillHints.newPassword],
decoration: InputDecoration(
hintText: "password",
contentPadding: EdgeInsets.all(12),
suffixIcon: IconButton(
icon: Icon(
_passwordVisible
? Icons.visibility
: Icons.visibility_off,
color: Colors.white.withOpacity(0.5),
size: 20,
),
onPressed: () {
_passwordVisible = !_passwordVisible;
setState(() {});
},
),
),
obscureText: !_passwordVisible,
controller: _textFieldController,
autofocus: true,
autocorrect: false,
keyboardType: TextInputType.visiblePassword,
onChanged: (_) {
setState(() {});
},
),
actions: <Widget>[
TextButton(
child: Text(
'cancel',
style: TextStyle(
color: Colors.white,
),
),
onPressed: () {
Navigator.pop(context, 'cancel');
},
),
TextButton(
child: Text(
'ok',
style: TextStyle(
color: Colors.white,
),
),
onPressed: () {
if (_textFieldController.text.trim().isEmpty) {
return;
}
Navigator.pop(context, 'ok');
},
),
],
);
});
});
}
Future<Map<String, dynamic>> _getEncryptedPassword(String pass) async {
assert(Sodium.cryptoPwhashAlgArgon2id13 == Sodium.cryptoPwhashAlgDefault,
"mismatch in expected default pw hashing algo");
int memLimit = Sodium.cryptoPwhashMemlimitInteractive;
int opsLimit = Sodium.cryptoPwhashOpslimitInteractive;
final kekSalt = CryptoUtil.getSaltToDeriveKey();
final result = await CryptoUtil.deriveKey(
utf8.encode(pass), kekSalt, memLimit, opsLimit);
return {
'passHash': Sodium.bin2base64(result),
'nonce': Sodium.bin2base64(kekSalt),
'memLimit': memLimit,
'opsLimit': opsLimit,
};
}
Future<void> _updateUrlSettings(
BuildContext context, Map<String, dynamic> prop) async {
final dialog = createProgressDialog(context, "please wait...");
await dialog.show();
try {
await CollectionsService.instance.updateShareUrl(widget.collection, prop);
await dialog.hide();
showToast("album updated");
} catch (e) {
await dialog.hide();
await showGenericErrorDialog(context);
}
}
Text _getLinkExpiryTimeWidget() {
int validTill = widget.collection.publicURLs?.first?.validTill ?? 0;
if (validTill == 0) {
return Text(
'never',
style: TextStyle(
color: Colors.grey,
),
);
}
if (validTill < DateTime.now().microsecondsSinceEpoch) {
return Text(
'expired',
style: TextStyle(
color: Colors.orange[300],
),
);
}
return Text(
getFormattedTime(DateTime.fromMicrosecondsSinceEpoch(validTill)),
style: TextStyle(
color: Colors.grey,
),
);
}
Future<void> _showDeviceLimitPicker() async {
List<Text> options = [];
for (int i = 50; i > 0; i--) {
options.add(Text(i.toString(), style: TextStyle(color: Colors.white)));
}
return showCupertinoModalPopup(
context: context,
builder: (context) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
border: Border(
bottom: BorderSide(
color: Color(0xff999999),
width: 0.0,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
CupertinoButton(
child: Text('cancel',
style: TextStyle(
color: Colors.white,
)),
onPressed: () {
Navigator.of(context).pop('cancel');
},
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 5.0,
),
),
CupertinoButton(
child: Text('confirm',
style: TextStyle(
color: Colors.white,
)),
onPressed: () async {
await _updateUrlSettings(context, {
'deviceLimit': int.tryParse(
options[_selectedDeviceLimitIndex].data),
});
setState(() {});
Navigator.of(context).pop('');
},
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 2.0,
),
)
],
),
),
Container(
height: 220.0,
color: Color(0xfff7f7f7),
child: CupertinoPicker(
backgroundColor: Colors.black.withOpacity(0.95),
children: options,
onSelectedItemChanged: (value) {
_selectedDeviceLimitIndex = value;
},
magnification: 1.3,
useMagnifier: true,
itemExtent: 25,
diameterRatio: 1,
),
)
],
);
},
);
}
}

View file

@ -16,9 +16,11 @@ import 'package:photos/services/user_service.dart';
import 'package:photos/ui/common/dialogs.dart';
import 'package:photos/ui/common_elements.dart';
import 'package:photos/ui/loading_widget.dart';
import 'package:photos/ui/manage_links_widget.dart';
import 'package:photos/ui/payment/subscription.dart';
import 'package:photos/utils/dialog_util.dart';
import 'package:photos/utils/email_util.dart';
import 'package:photos/utils/navigation_util.dart';
import 'package:photos/utils/share_util.dart';
import 'package:photos/utils/toast_util.dart';
@ -138,7 +140,10 @@ class _SharingDialogState extends State<SharingDialog> {
),
]);
if (widget.collection.publicURLs?.isNotEmpty ?? false) {
children.add(_getShareableUrlWidget());
children.add(Padding(
padding: EdgeInsets.all(2),
));
children.add(_getShareableUrlWidget(context));
}
}
@ -155,6 +160,7 @@ class _SharingDialogState extends State<SharingDialog> {
],
),
),
contentPadding: EdgeInsets.fromLTRB(24, 24, 24, 4),
);
}
@ -209,49 +215,51 @@ class _SharingDialogState extends State<SharingDialog> {
);
}
Widget _getShareableUrlWidget() {
Widget _getShareableUrlWidget(BuildContext parentContext) {
String collectionKey = Base58Encode(
CollectionsService.instance.getCollectionKey(widget.collection.id));
String url = "${widget.collection.publicURLs.first.url}#$collectionKey";
return SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: EdgeInsets.all(4)),
GestureDetector(
onTap: () async {
await Clipboard.setData(ClipboardData(text: url));
showToast("link copied to clipboard");
},
child: Container(
padding: EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: Text(
url,
style: TextStyle(
fontSize: 16,
fontFeatures: const [FontFeature.tabularFigures()],
color: Colors.white.withOpacity(0.68),
overflow: TextOverflow.ellipsis,
),
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: EdgeInsets.all(4)),
GestureDetector(
onTap: () async {
await Clipboard.setData(ClipboardData(text: url));
showToast("link copied to clipboard");
},
child: Container(
padding: EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: Text(
url,
style: TextStyle(
fontSize: 16,
fontFeatures: const [FontFeature.tabularFigures()],
color: Colors.white.withOpacity(0.68),
overflow: TextOverflow.ellipsis,
),
),
Padding(padding: EdgeInsets.all(2)),
Icon(
Icons.copy,
size: 18,
),
],
),
color: Colors.white.withOpacity(0.02),
),
Padding(padding: EdgeInsets.all(2)),
Icon(
Icons.copy,
size: 18,
),
],
),
color: Colors.white.withOpacity(0.02),
),
Padding(padding: EdgeInsets.all(2)),
TextButton(
),
Padding(padding: EdgeInsets.all(2)),
TextButton(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -270,12 +278,31 @@ class _SharingDialogState extends State<SharingDialog> {
),
],
),
onPressed: () {
shareText(url);
// _shareRecoveryKey(recoveryKey);
},
),
]),
onPressed: () {
shareText(url);
},
),
Padding(padding: EdgeInsets.all(4)),
TextButton(
child: Center(
child: Text(
"manage link",
style: TextStyle(
color: Colors.white70,
decoration: TextDecoration.underline,
),
),
),
onPressed: () async {
routeToPage(
parentContext,
ManageSharedLinkWidget(collection: widget.collection),
);
},
),
],
),
);
}

View file

@ -1152,6 +1152,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.0"
tuple:
dependency: "direct main"
description:
name: tuple
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
typed_data:
dependency: transitive
description:

View file

@ -11,7 +11,7 @@ description: ente photos application
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.5.5+284
version: 0.5.6+284
environment:
sdk: ">=2.10.0 <3.0.0"
@ -96,6 +96,7 @@ dependencies:
path: thirdparty/super_logging
syncfusion_flutter_core: ^19.2.49
syncfusion_flutter_sliders: ^19.2.49
tuple: ^2.0.0
uni_links: ^0.5.1
url_launcher: ^6.0.3
uuid: ^3.0.4