Skip to content

Commit

Permalink
Started to handle rate limiting
Browse files Browse the repository at this point in the history
Fixed bug in early disposal of the photo card
  • Loading branch information
tvolkert committed Jun 30, 2019
1 parent 3a727ab commit 25045b0
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 54 deletions.
37 changes: 29 additions & 8 deletions lib/src/model/photo_card_producer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import 'dart:convert';
import 'dart:io';

import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';

Expand All @@ -19,15 +18,17 @@ typedef PhotoCardProducerBuilder = PhotoCardProducer Function(
PhotoMontage montage,
);

class PhotoCardProducer {
PhotoCardProducer(this.model, this.montage) {
debugPrint('creating new producer - ${StackTrace.current}');
}
abstract class PhotoCardProducer {
factory PhotoCardProducer(
PhotosLibraryApiModel model,
PhotoMontage montage,
) = _ApiPhotoCardProducer;

static const Duration interval = Duration(seconds: 1, milliseconds: 750);
factory PhotoCardProducer.asset(PhotoMontage montage) = _StaticPhotoCardProducer;

final PhotosLibraryApiModel model;
final PhotoMontage montage;
PhotoCardProducer._();

static const Duration interval = Duration(seconds: 1, milliseconds: 750);

Timer _timer;

Expand All @@ -51,6 +52,16 @@ class PhotoCardProducer {
_timer = Timer(interval * timeDilation, _addCard);
}

Future<void> _addCard();
}

class _ApiPhotoCardProducer extends PhotoCardProducer {
_ApiPhotoCardProducer(this.model, this.montage) : super._();

final PhotosLibraryApiModel model;
final PhotoMontage montage;

@override
Future<void> _addCard() async {
Directory documentsDirectory = await getApplicationDocumentsDirectory();
File photosFile = File(path.join(documentsDirectory.path, 'photos'));
Expand Down Expand Up @@ -87,3 +98,13 @@ class PhotoCardProducer {
_scheduleProduce();
}
}

class _StaticPhotoCardProducer extends PhotoCardProducer {
_StaticPhotoCardProducer(this.montage) : super._();

final PhotoMontage montage;

@override
Future<void> _addCard() async {
}
}
21 changes: 6 additions & 15 deletions lib/src/model/photo_cards.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';

import 'package:flutter/foundation.dart';
Expand All @@ -7,7 +7,6 @@ import 'package:flutter/scheduler.dart';
import 'package:scoped_model/scoped_model.dart';

import '../photos_library_api/media_item.dart';
import 'http_status_exception.dart';
import 'photo.dart';
import 'random.dart';

Expand Down Expand Up @@ -55,32 +54,24 @@ class PhotoMontage extends Model {
}

PhotoColumn column = candidates[random.nextInt(candidates.length)];
Photo photo = await _loadPhoto(mediaItem, column);
Size sizeConstraints = Size.square(column.width * window.physicalSize.width);
Photo photo = await _loadPhoto(mediaItem, sizeConstraints);

PhotoCard card = PhotoCard._(photo: photo, column: column);
column._cards.insert(0, card);
notifyListeners();
return card;
}

Future<Photo> _loadPhoto(MediaItem mediaItem, PhotoColumn column) async {
Size sizeConstraints = Size.square(column.width * window.physicalSize.width);
Future<Photo> _loadPhoto(MediaItem mediaItem, Size sizeConstraints) async {
Size photoSize = applyBoxFit(BoxFit.scaleDown, mediaItem.size, sizeConstraints).destination;
String url = '${mediaItem.baseUrl}=w${photoSize.width.toInt()}-h${photoSize.height.toInt()}';

final HttpClient httpClient = HttpClient();
final Uri resolved = Uri.base.resolve(url);
final HttpClientRequest request = await httpClient.getUrl(resolved);
final HttpClientResponse response = await request.close();
if (response.statusCode != HttpStatus.ok) {
throw HttpStatusException(response.statusCode);
}
Uint8List bytes = await mediaItem.load(photoSize);

return Photo(
mediaItem,
photoSize / window.devicePixelRatio,
window.devicePixelRatio,
await consolidateHttpClientResponseBytes(response),
bytes,
);
}

Expand Down
73 changes: 47 additions & 26 deletions lib/src/model/photos_library_api_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,27 @@ import '../photos_library_api/photos_library_api_client.dart';
import '../photos_library_api/search_media_items_request.dart';
import '../photos_library_api/search_media_items_response.dart';

enum AuthState {
pending,
enum PhotosLibraryApiState {
/// The user has not yet attempted authentication or is in the process of
/// authenticating.
pendingAuthentication,

/// The user has authenticated and is in possession of a valid OAuth 2.0
/// access token.
authenticated,

/// The user has attempted authentication and failed, or their OAuth access
/// token has expired and in need of renewal.
unauthenticated,

/// The user has successfully authenticated, but they are being rate limited
/// by the Google Photos API and are temporarily not allowed to make further
/// API requests.
rateLimited,
}

class PhotosLibraryApiModel extends Model {
AuthState _authState = AuthState.pending;
PhotosLibraryApiState _state = PhotosLibraryApiState.pendingAuthentication;
PhotosLibraryApiClient _client;
GoogleSignInAccount _currentUser;

Expand All @@ -33,22 +46,19 @@ class PhotosLibraryApiModel extends Model {

Future<void> _onSignInComplete({bool fetchPhotos = true}) async {
_currentUser = _googleSignIn.currentUser;
AuthState newAuthState;
PhotosLibraryApiState newState;
if (_currentUser == null) {
newAuthState = AuthState.unauthenticated;
newState = PhotosLibraryApiState.unauthenticated;
_client = null;
} else {
newAuthState = AuthState.authenticated;
newState = PhotosLibraryApiState.authenticated;
_client = PhotosLibraryApiClient(_currentUser.authHeaders);
}
if (fetchPhotos && newAuthState == AuthState.authenticated) {
if (fetchPhotos && newState == PhotosLibraryApiState.authenticated) {
// TODO(tvolkert): don't delete, but don't blindly append
await _populatePhotosFile(delete: false);
}
if (_authState != newAuthState) {
_authState = newAuthState;
notifyListeners();
}
state = newState;
}

Future<void> _populatePhotosFile({bool delete = false}) async {
Expand Down Expand Up @@ -89,25 +99,32 @@ class PhotosLibraryApiModel extends Model {
}
}

AuthState get authState => _authState;
PhotosLibraryApiState get state => _state;
set state(PhotosLibraryApiState value) {
assert(value != null);
if (_state != value) {
_state = value;
notifyListeners();
}
}

Future<bool> signIn() async {
if (_currentUser != null) {
assert(authState == AuthState.authenticated);
assert(state == PhotosLibraryApiState.authenticated);
return true;
}

assert(authState != AuthState.authenticated);
assert(state != PhotosLibraryApiState.authenticated);
await _googleSignIn.signIn();
await _onSignInComplete();
return authState == AuthState.authenticated;
return state == PhotosLibraryApiState.authenticated;
}

Future<void> signOut() async {
await _googleSignIn.disconnect();
_currentUser = null;
_authState = AuthState.unauthenticated;
_client = null;
state = PhotosLibraryApiState.unauthenticated;
}

Future<void> signInSilently({bool fetchPhotosAfterSignIn = true}) async {
Expand All @@ -134,17 +151,21 @@ class PhotosLibraryApiModel extends Model {
try {
result = await _client.getMediaItem(id);
} on GetMediaItemException catch (error) {
if (error.statusCode == HttpStatus.unauthorized) {
// Need to renew our OAuth access token.
debugPrint('Renewing OAuth access token...');
await signInSilently(fetchPhotosAfterSignIn: false);
if (authState == AuthState.unauthenticated) {
// Unable to renew OAuth token.
debugPrint('Unable to renew OAuth access token; bailing out');
switch (error.statusCode) {
case HttpStatus.unauthorized:
debugPrint('Renewing OAuth access token...');
await signInSilently(fetchPhotosAfterSignIn: false);
if (state == PhotosLibraryApiState.unauthenticated) {
debugPrint('Unable to renew OAuth access token; bailing out');
return null;
}
break;
case HttpStatus.tooManyRequests:
state = PhotosLibraryApiState.rateLimited;
return null;
}
} else {
rethrow;
break;
default:
rethrow;
}
}
}
Expand Down
18 changes: 13 additions & 5 deletions lib/src/pages/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,27 @@ class HomePage extends StatelessWidget {
Widget build(BuildContext context) {
return ScopedModelDescendant<PhotosLibraryApiModel>(
builder: (BuildContext context, Widget child, PhotosLibraryApiModel apiModel) {
switch (apiModel.authState) {
case AuthState.pending:
switch (apiModel.state) {
case PhotosLibraryApiState.pendingAuthentication:
// Show a blank screen while we try to non-interactively sign in.
return Container();
case AuthState.unauthenticated:
case PhotosLibraryApiState.unauthenticated:
return interactive ? LoginPage() : InteractiveLoginRequiredPage();
case AuthState.authenticated:
case PhotosLibraryApiState.authenticated:
return PhotosHome(
montageBuilder: montageBuilder,
producerBuilder: producerBuilder,
);
case PhotosLibraryApiState.rateLimited:
return PhotosHome(
montageBuilder: montageBuilder,
producerBuilder: (PhotosLibraryApiModel model, PhotoMontage montage) {
return PhotoCardProducer.asset(montage);
},
);
break;
default:
throw StateError('Auth state not supported: ${apiModel.authState}');
throw StateError('Auth state not supported: ${apiModel.state}');
}
},
);
Expand Down
1 change: 1 addition & 0 deletions lib/src/pages/photos_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ class _FLoatingPhotoState extends State<FLoatingPhoto> with SingleTickerProvider
height: widget.card.column.width * screenSize.width,
child: Image.memory(
widget.card.photo.bytes,
alignment: Alignment.topCenter,
scale: widget.card.photo.scale,
),
),
Expand Down
File renamed without changes.
18 changes: 18 additions & 0 deletions lib/src/photos_library_api/media_item.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';

import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';

import 'http_status_exception.dart';
import 'media_metadata.dart';

part 'media_item.g.dart';
Expand Down Expand Up @@ -67,5 +71,19 @@ class MediaItem {
return Size(width.toDouble(), height.toDouble());
}

Future<Uint8List> load(Size size) async {
String url = '$baseUrl=w${size.width.toInt()}-h${size.height.toInt()}';

final HttpClient httpClient = HttpClient();
final Uri resolved = Uri.base.resolve(url);
final HttpClientRequest request = await httpClient.getUrl(resolved);
final HttpClientResponse response = await request.close();
if (response.statusCode != HttpStatus.ok) {
throw HttpStatusException(response.statusCode);
}

return await consolidateHttpClientResponseBytes(response);
}

Map<String, dynamic> toJson() => _$MediaItemToJson(this);
}

0 comments on commit 25045b0

Please sign in to comment.