Add endpoint discovery mechanism

This commit is contained in:
Vishnu Mohandas 2020-04-30 20:39:41 +05:30
parent 2d3c821932
commit d228f7278f
13 changed files with 572 additions and 36 deletions

View file

@ -0,0 +1,49 @@
import 'package:shared_preferences/shared_preferences.dart';
class Configuration {
Configuration._privateConstructor();
static final Configuration instance = Configuration._privateConstructor();
static const endpointKey = "endpoint_7";
static const tokenKey = "token";
static const usernameKey = "username";
static const passwordKey = "password";
SharedPreferences preferences;
Future<void> init() async {
preferences = await SharedPreferences.getInstance();
}
String getEndpoint() {
return preferences.getString(endpointKey);
}
void setEndpoint(String endpoint) async {
await preferences.setString(endpointKey, endpoint);
}
String getToken() {
return preferences.getString(tokenKey);
}
void setToken(String token) async {
await preferences.setString(tokenKey, token);
}
String getUsername() {
return preferences.getString(usernameKey);
}
void setUsername(String username) async {
await preferences.setString(usernameKey, username);
}
String getPassword() {
return preferences.getString(passwordKey);
}
void setPassword(String password) async {
await preferences.setString(passwordKey, password);
}
}

5
lib/core/event_bus.dart Normal file
View file

@ -0,0 +1,5 @@
import 'package:event_bus/event_bus.dart';
class Bus {
static final EventBus instance = EventBus();
}

View file

@ -0,0 +1,5 @@
class RemoteSyncEvent {
final bool success;
RemoteSyncEvent(this.success);
}

View file

@ -0,0 +1 @@
class UserAuthenticatedEvent {}

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:logger/logger.dart';
import 'package:myapp/core/configuration.dart';
import 'package:myapp/photo_loader.dart';
import 'package:myapp/photo_provider.dart';
import 'package:myapp/photo_sync_manager.dart';
import 'package:myapp/ui/home_widget.dart';
import 'package:provider/provider.dart';
@ -9,9 +9,8 @@ import 'package:provider/provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(MyApp());
PhotoProvider.instance
.refreshGalleryList()
.then((_) => PhotoSyncManager.instance.load(PhotoProvider.instance.list));
Configuration.instance.init();
PhotoSyncManager.instance.sync();
}
class MyApp extends StatelessWidget with WidgetsBindingObserver {
@ -33,9 +32,7 @@ class MyApp extends StatelessWidget with WidgetsBindingObserver {
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
PhotoProvider.instance.refreshGalleryList().then((_) {
return PhotoSyncManager.instance.load(PhotoProvider.instance.list);
});
PhotoSyncManager.instance.sync();
}
}
}

View file

@ -2,8 +2,11 @@ import 'dart:async';
import 'dart:io';
import 'package:logger/logger.dart';
import 'package:myapp/core/event_bus.dart';
import 'package:myapp/db/db_helper.dart';
import 'package:myapp/events/user_authenticated_event.dart';
import 'package:myapp/photo_loader.dart';
import 'package:myapp/photo_provider.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
@ -12,25 +15,33 @@ import 'package:dio/dio.dart';
import 'package:myapp/models/photo.dart';
import 'package:myapp/core/constants.dart' as Constants;
import 'events/remote_sync_event.dart';
class PhotoSyncManager {
final _logger = Logger();
final _dio = Dio();
bool _isLoadInProgress = false;
bool _isSyncInProgress = false;
static final _lastSyncTimestampKey = "last_sync_timestamp_0";
static final _lastDBUpdateTimestampKey = "last_db_update_timestamp";
PhotoSyncManager._privateConstructor();
PhotoSyncManager._privateConstructor() {
Bus.instance.on<UserAuthenticatedEvent>().listen((event) {
sync();
});
}
static final PhotoSyncManager instance =
PhotoSyncManager._privateConstructor();
Future<void> load(List<AssetPathEntity> pathEntities) async {
if (_isLoadInProgress) {
_logger.w("Load already in progress, skipping.");
Future<void> sync() async {
if (_isSyncInProgress) {
_logger.w("Sync already in progress, skipping.");
return;
}
_isLoadInProgress = true;
_logger.i("Loading...");
_isSyncInProgress = true;
_logger.i("Syncing...");
final prefs = await SharedPreferences.getInstance();
var lastDBUpdateTimestamp = prefs.getInt(_lastDBUpdateTimestampKey);
if (lastDBUpdateTimestamp == null) {
@ -38,6 +49,8 @@ class PhotoSyncManager {
await _initializeDirectories();
}
await PhotoProvider.instance.refreshGalleryList();
final pathEntities = PhotoProvider.instance.list;
final photos = List<Photo>();
for (AssetPathEntity pathEntity in pathEntities) {
if (Platform.isIOS || pathEntity.name != "Recent") {
@ -56,16 +69,15 @@ class PhotoSyncManager {
}
}
if (photos.isEmpty) {
_isLoadInProgress = false;
return;
_isSyncInProgress = false;
_syncPhotos().then((_) {
_deletePhotos();
});
} else {
photos.sort((first, second) =>
first.createTimestamp.compareTo(second.createTimestamp));
_updateDatabase(photos, prefs, lastDBUpdateTimestamp).then((_) {
_isLoadInProgress = false;
_syncPhotos().then((_) {
_deletePhotos();
});
_isSyncInProgress = false;
});
}
}
@ -91,9 +103,11 @@ class PhotoSyncManager {
_logger.i("Last sync timestamp: " + lastSyncTimestamp.toString());
_getDiff(lastSyncTimestamp).then((diff) {
_downloadDiff(diff, prefs).then((_) {
_uploadDiff(prefs);
});
if (diff != null) {
_downloadDiff(diff, prefs).then((_) {
_uploadDiff(prefs);
});
}
});
// TODO: Fix race conditions triggered due to concurrent syncs.
@ -121,7 +135,7 @@ class PhotoSyncManager {
var localPath = path + basename(photo.remotePath);
await _dio
.download(Constants.ENDPOINT + "/" + photo.remotePath, localPath)
.catchError(_onError);
.catchError((e) => _logger.e(e));
// TODO: Save path
photo.pathName = localPath;
await DatabaseHelper.instance.insertPhoto(photo);
@ -135,14 +149,15 @@ class PhotoSyncManager {
queryParameters: {
"user": Constants.USER,
"lastSyncTimestamp": lastSyncTimestamp
}).catchError(_onError);
_logger.i(response.toString());
}).catchError((e) => _logger.e(e));
if (response != null) {
Bus.instance.fire(RemoteSyncEvent(true));
return (response.data["diff"] as List)
.map((photo) => new Photo.fromJson(photo))
.toList();
} else {
return List<Photo>();
Bus.instance.fire(RemoteSyncEvent(false));
return null;
}
}
@ -158,7 +173,7 @@ class PhotoSyncManager {
_logger.i(response.toString());
var photo = Photo.fromJson(response.data);
return photo;
}).catchError(_onError);
}).catchError((e) => _logger.e(e));
}
Future<void> _deletePhotos() async {
@ -174,11 +189,7 @@ class PhotoSyncManager {
return _dio.post(Constants.ENDPOINT + "/delete", queryParameters: {
"user": Constants.USER,
"fileID": photo.uploadedFileId
}).catchError((e) => _onError(e));
}
void _onError(error) {
_logger.e(error);
}).catchError((e) => _logger.e(e));
}
Future _initializeDirectories() async {

View file

@ -1,8 +1,12 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:logger/logger.dart';
import 'package:myapp/core/event_bus.dart';
import 'package:myapp/db/db_helper.dart';
import 'package:myapp/events/remote_sync_event.dart';
import 'package:myapp/models/photo.dart';
import 'package:myapp/photo_loader.dart';
import 'package:myapp/ui/setup_page.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:myapp/utils/share_util.dart';
@ -24,10 +28,25 @@ class GalleryAppBarWidget extends StatefulWidget
}
class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
bool _hasSyncErrors = false;
@override
void initState() {
Bus.instance.on<RemoteSyncEvent>().listen((event) {
setState(() {
_hasSyncErrors = !event.success;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
if (widget.selectedPhotos.isEmpty) {
return AppBar(title: Text(widget.title));
return AppBar(
title: Text(widget.title),
actions: _getDefaultActions(context),
);
}
return AppBar(
@ -38,11 +57,24 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
},
),
title: Text(widget.selectedPhotos.length.toString()),
actions: _getActions(context),
actions: _getPhotoActions(context),
);
}
List<Widget> _getActions(BuildContext context) {
List<Widget> _getDefaultActions(BuildContext context) {
List<Widget> actions = List<Widget>();
if (_hasSyncErrors) {
actions.add(IconButton(
icon: Icon(Icons.sync_problem),
onPressed: () {
_openSyncConfiguration(context);
},
));
}
return actions;
}
List<Widget> _getPhotoActions(BuildContext context) {
List<Widget> actions = List<Widget>();
if (widget.selectedPhotos.isNotEmpty) {
actions.add(IconButton(
@ -119,4 +151,16 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
widget.onSelectionClear();
}
}
void _openSyncConfiguration(BuildContext context) {
final page = SetupPage();
Navigator.of(context).push(
MaterialPageRoute(
settings: RouteSettings(name: "/setup"),
builder: (BuildContext context) {
return page;
},
),
);
}
}

190
lib/ui/setup_page.dart Normal file
View file

@ -0,0 +1,190 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:logger/logger.dart';
import 'package:myapp/core/configuration.dart';
import 'package:myapp/ui/sign_in_widget.dart';
import 'package:myapp/utils/endpoint_finder.dart';
class SetupPage extends StatefulWidget {
SetupPage({key}) : super(key: key);
@override
_SetupPageState createState() => _SetupPageState();
}
class _SetupPageState extends State<SetupPage> {
bool _hasFoundEndpoint = Configuration.instance.getEndpoint() != null;
bool _errorFindingEndpoint = false;
String _enteredEndpoint = "";
@override
Widget build(BuildContext context) {
_hasFoundEndpoint = Configuration.instance.getEndpoint() != null;
if (!_hasFoundEndpoint && !_errorFindingEndpoint) {
EndpointFinder.instance.findEndpoint().then((endpoint) {
setState(() {
Configuration.instance.setEndpoint(endpoint);
});
}).catchError((e) {
setState(() {
_errorFindingEndpoint = true;
});
});
}
return Scaffold(
appBar: AppBar(
title: Text("Setup"),
),
body: _getBody(),
);
}
Widget _getBody() {
if (!_hasFoundEndpoint && !_errorFindingEndpoint) {
return _getSearchScreen();
} else if (!_hasFoundEndpoint && _errorFindingEndpoint) {
return _getManualEndpointEntryScreen();
} else {
return SignInWidget(() {
setState(() {});
});
}
}
Widget _getManualEndpointEntryScreen() {
return Container(
margin: EdgeInsets.all(12),
child: Column(
children: <Widget>[
Text("Please enter the IP address of the ente server manually."),
TextField(
decoration: InputDecoration(
hintText: '192.168.1.1',
contentPadding: EdgeInsets.all(20),
),
autofocus: true,
autocorrect: false,
onChanged: (value) {
setState(() {
_enteredEndpoint = value;
});
},
),
CupertinoButton(
child: Text("Connect"),
onPressed: () async {
try {
bool success =
await EndpointFinder.instance.ping(_enteredEndpoint);
if (success) {
setState(() {
_errorFindingEndpoint = false;
Configuration.instance.setEndpoint(_enteredEndpoint);
});
} else {
_showPingErrorDialog();
}
} catch (e) {
_showPingErrorDialog();
}
},
),
],
),
);
}
Center _getSearchScreen() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
AnimatedSearchIconWidget(),
Text("Searching for ente server..."),
],
),
);
}
void _showPingErrorDialog() {
showDialog<void>(
context: context,
barrierDismissible: false, // user must tap button!
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text('Connection failed'),
content: Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
child: Text(
'Please make sure that the server is running and reachable.'),
),
actions: <Widget>[
CupertinoDialogAction(
child: Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
}
class AnimatedSearchIconWidget extends StatefulWidget {
AnimatedSearchIconWidget({
Key key,
}) : super(key: key);
@override
_AnimatedSearchIconWidgetState createState() =>
_AnimatedSearchIconWidgetState();
}
class _AnimatedSearchIconWidgetState extends State<AnimatedSearchIconWidget>
with SingleTickerProviderStateMixin {
Animation<double> _animation;
AnimationController _controller;
@override
void initState() {
super.initState();
_controller =
AnimationController(duration: const Duration(seconds: 1), vsync: this);
_animation = Tween<double>(begin: 100, end: 200).animate(_controller)
..addListener(() {
setState(() {
// The state that has changed here is the animation objects value.
});
})
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_controller.reverse();
} else if (status == AnimationStatus.dismissed) {
_controller.forward();
}
});
_controller.forward();
}
@override
Widget build(BuildContext context) {
return Container(
child: Icon(
Icons.search,
size: _animation.value,
),
width: 200,
height: 200,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}

109
lib/ui/sign_in_widget.dart Normal file
View file

@ -0,0 +1,109 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:myapp/core/configuration.dart';
import 'package:myapp/user_authenticator.dart';
class SignInWidget extends StatefulWidget {
final Function() onReconfigurationRequested;
const SignInWidget(
this.onReconfigurationRequested, {
Key key,
}) : super(key: key);
@override
_SignInWidgetState createState() => _SignInWidgetState();
}
class _SignInWidgetState extends State<SignInWidget> {
String _username, _password;
@override
void initState() {
_username = Configuration.instance.getUsername();
_password = Configuration.instance.getPassword();
super.initState();
}
@override
Widget build(BuildContext context) {
return Container(
child: Column(
children: <Widget>[
TextFormField(
initialValue: _username,
decoration: InputDecoration(
hintText: 'username',
contentPadding: EdgeInsets.all(20),
),
autofocus: true,
autocorrect: false,
onChanged: (value) {
setState(() {
_username = value;
});
},
),
TextFormField(
initialValue: _password,
decoration: InputDecoration(
hintText: 'password',
contentPadding: EdgeInsets.all(20),
),
autocorrect: false,
obscureText: true,
onChanged: (value) {
setState(() {
_password = value;
});
},
),
CupertinoButton(
child: Text("Sign In"),
onPressed: () async {
final loggedIn =
await UserAuthenticator.instance.login(_username, _password);
if (loggedIn) {
Navigator.of(context).pop();
} else {
_showErrorDialog();
}
},
),
],
));
}
void _showErrorDialog() {
showDialog<void>(
context: context,
barrierDismissible: false, // user must tap button!
builder: (BuildContext context) {
return CupertinoAlertDialog(
title: Text('Login failed'),
content: Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 0),
child: Text(
'Please make sure that the credentials entered are correct.'),
),
actions: <Widget>[
CupertinoDialogAction(
child: Text('Reconfigure'),
onPressed: () {
Navigator.of(context).pop();
Configuration.instance.setEndpoint(null);
widget.onReconfigurationRequested();
},
),
CupertinoDialogAction(
child: Text('OK'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
}

View file

@ -0,0 +1,38 @@
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:myapp/core/configuration.dart';
import 'package:myapp/core/event_bus.dart';
import 'events/user_authenticated_event.dart';
class UserAuthenticator {
final _dio = Dio();
final _logger = Logger();
UserAuthenticator._privateConstructor();
static final UserAuthenticator instance =
UserAuthenticator._privateConstructor();
Future<bool> login(String username, String password) {
return _dio.post(
"http://" + Configuration.instance.getEndpoint() + ":8080/users/login",
queryParameters: {
"username": username,
"password": password
}).then((response) {
if (response.statusCode == 200 && response.data != null) {
Configuration.instance.setUsername(username);
Configuration.instance.setPassword(password);
Configuration.instance.setToken(response.data["token"]);
Bus.instance.fire(UserAuthenticatedEvent());
return true;
} else {
return false;
}
}).catchError((e) {
_logger.e(e.toString());
return false;
});
}
}

View file

@ -0,0 +1,57 @@
import 'dart:async';
import 'package:connectivity/connectivity.dart';
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
class EndpointFinder {
final _dio = Dio();
EndpointFinder._privateConstructor() {
_dio.options = BaseOptions(connectTimeout: 200);
}
static final EndpointFinder instance = EndpointFinder._privateConstructor();
Future<String> findEndpoint() {
return (Connectivity().getWifiIP()).then((ip) async {
Logger().i(ip);
final ipSplit = ip.split(".");
var prefix = "";
for (int index = 0; index < ipSplit.length; index++) {
if (index != ipSplit.length - 1) {
prefix += ipSplit[index] + ".";
}
}
Logger().i(prefix);
for (int i = 1; i <= 10; i++) {
var endpoint = prefix + i.toString();
if (i == 300) {
endpoint = "192.168.0.101";
}
Logger().i("Trying " + endpoint);
try {
final success = await ping(endpoint);
if (success) {
return endpoint;
}
} catch (e) {
// Do nothing
}
}
throw TimeoutException("Could not find a valid endpoint");
});
}
Future<bool> ping(String endpoint) async {
return _dio.get("http://" + endpoint + ":8080/ping").then((response) {
if (response.data["message"] == "pong") {
Logger().i("Found " + endpoint);
return true;
} else {
return false;
}
});
}
}

View file

@ -43,6 +43,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.14.11"
connectivity:
dependency: "direct main"
description:
name: connectivity
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.8+2"
connectivity_macos:
dependency: transitive
description:
name: connectivity_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0+2"
connectivity_platform_interface:
dependency: transitive
description:
name: connectivity_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
convert:
dependency: transitive
description:
@ -85,6 +106,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
event_bus:
dependency: "direct main"
description:
name: event_bus
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter

View file

@ -39,6 +39,8 @@ dependencies:
photo_view: ^0.9.2
flutter_image_compress: ^0.6.5+1
visibility_detector: ^0.1.4
connectivity: ^0.4.8+2
event_bus: ^1.1.1
dev_dependencies:
flutter_test: