Complete Phase 5B pairing flow
Add agent pairing token lifecycle, APK download serving, MemoriC Google sign-in and pairing UX polish, and update implementation plans for Phase 5C.
This commit is contained in:
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const brokerUrl = 'https://memory.clouderized.com';
|
||||
const googleServerClientId = String.fromEnvironment('GOOGLE_SERVER_CLIENT_ID');
|
||||
|
||||
void main() => runApp(const MemoryApp());
|
||||
|
||||
@@ -64,6 +66,13 @@ class DirectUploadConfig {
|
||||
);
|
||||
}
|
||||
|
||||
class AuthResult {
|
||||
final bool ok;
|
||||
final String message;
|
||||
|
||||
const AuthResult(this.ok, this.message);
|
||||
}
|
||||
|
||||
class UploadPage extends StatefulWidget {
|
||||
const UploadPage({super.key});
|
||||
|
||||
@@ -80,16 +89,23 @@ class _UploadPageState extends State<UploadPage> {
|
||||
bool _agentConnected = false;
|
||||
Timer? _statusTimer;
|
||||
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
|
||||
final GoogleSignIn _googleSignIn = GoogleSignIn(
|
||||
scopes: ['email'],
|
||||
serverClientId: googleServerClientId == '' ? null : googleServerClientId,
|
||||
);
|
||||
late final TextEditingController _tokenController;
|
||||
late final TextEditingController _googleIdTokenController;
|
||||
String _accessToken = '';
|
||||
String _refreshToken = '';
|
||||
String _userEmail = '';
|
||||
String _userDisplayName = '';
|
||||
String _userPictureUrl = '';
|
||||
String _agentLanEndpoint = '';
|
||||
bool _editingPairing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tokenController = TextEditingController();
|
||||
_googleIdTokenController = TextEditingController();
|
||||
_loadBrokerSession();
|
||||
_loadDirectUploadConfig();
|
||||
_loadGallery();
|
||||
@@ -103,7 +119,6 @@ class _UploadPageState extends State<UploadPage> {
|
||||
void dispose() {
|
||||
_statusTimer?.cancel();
|
||||
_tokenController.dispose();
|
||||
_googleIdTokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -113,7 +128,13 @@ class _UploadPageState extends State<UploadPage> {
|
||||
setState(() {
|
||||
_accessToken = prefs.getString('broker_access_token') ?? '';
|
||||
_refreshToken = prefs.getString('broker_refresh_token') ?? '';
|
||||
_userEmail = prefs.getString('broker_user_email') ?? '';
|
||||
_userDisplayName = prefs.getString('broker_user_display_name') ?? '';
|
||||
_userPictureUrl = prefs.getString('broker_user_picture_url') ?? '';
|
||||
});
|
||||
if (_accessToken.isNotEmpty || _refreshToken.isNotEmpty) {
|
||||
unawaited(_fetchCurrentUser());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveBrokerSession({
|
||||
@@ -130,20 +151,63 @@ class _UploadPageState extends State<UploadPage> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveUserProfile(Map<String, dynamic> user) async {
|
||||
final email = (user['email'] ?? '').toString();
|
||||
final displayName = (user['display_name'] ?? '').toString();
|
||||
final pictureUrl = (user['picture_url'] ?? '').toString();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('broker_user_email', email);
|
||||
await prefs.setString('broker_user_display_name', displayName);
|
||||
await prefs.setString('broker_user_picture_url', pictureUrl);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_userEmail = email;
|
||||
_userDisplayName = displayName;
|
||||
_userPictureUrl = pictureUrl;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _fetchCurrentUser() async {
|
||||
try {
|
||||
final token = await _getValidAccessToken();
|
||||
if (token == null) return;
|
||||
final res = await http.get(
|
||||
Uri.parse('$brokerUrl/me'),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
if (res.statusCode != 200) return;
|
||||
final body = jsonDecode(res.body);
|
||||
if (body is Map<String, dynamic>) {
|
||||
await _saveUserProfile(body);
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep cached profile if refresh fails.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearBrokerSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('broker_access_token');
|
||||
await prefs.remove('broker_refresh_token');
|
||||
await prefs.remove('broker_user_email');
|
||||
await prefs.remove('broker_user_display_name');
|
||||
await prefs.remove('broker_user_picture_url');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_accessToken = '';
|
||||
_refreshToken = '';
|
||||
_userEmail = '';
|
||||
_userDisplayName = '';
|
||||
_userPictureUrl = '';
|
||||
_agentConnected = false;
|
||||
_agentLanEndpoint = '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _signInWithGoogleIdToken() async {
|
||||
final idToken = _googleIdTokenController.text.trim();
|
||||
if (idToken.isEmpty) return false;
|
||||
Future<AuthResult> _exchangeGoogleIdToken(String idToken) async {
|
||||
if (idToken.isEmpty) {
|
||||
return const AuthResult(false, 'Google did not return an ID token.');
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await http.post(
|
||||
@@ -151,18 +215,65 @@ class _UploadPageState extends State<UploadPage> {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'id_token': idToken}),
|
||||
);
|
||||
if (res.statusCode != 200) return false;
|
||||
if (res.statusCode != 200) {
|
||||
var detail = res.body.trim();
|
||||
try {
|
||||
final body = jsonDecode(res.body);
|
||||
if (body is Map && body['error'] != null) {
|
||||
detail = body['error'].toString();
|
||||
} else if (body is Map && body['detail'] != null) {
|
||||
detail = body['detail'].toString();
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep raw response body.
|
||||
}
|
||||
return AuthResult(
|
||||
false,
|
||||
'Broker rejected Google sign-in (${res.statusCode}): $detail',
|
||||
);
|
||||
}
|
||||
final body = jsonDecode(res.body);
|
||||
final accessToken = (body['access_token'] ?? '').toString();
|
||||
final refreshToken = (body['refresh_token'] ?? '').toString();
|
||||
if (accessToken.isEmpty || refreshToken.isEmpty) return false;
|
||||
if (accessToken.isEmpty || refreshToken.isEmpty) {
|
||||
return const AuthResult(
|
||||
false,
|
||||
'Broker response did not include a session.',
|
||||
);
|
||||
}
|
||||
await _saveBrokerSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
final user = body['user'];
|
||||
if (user is Map<String, dynamic>) {
|
||||
await _saveUserProfile(user);
|
||||
} else {
|
||||
await _fetchCurrentUser();
|
||||
}
|
||||
return const AuthResult(true, 'Broker sign-in successful.');
|
||||
} catch (e) {
|
||||
return AuthResult(false, 'Sign-in request failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AuthResult> _signInWithGoogle() async {
|
||||
try {
|
||||
final account = await _googleSignIn.signIn();
|
||||
if (account == null) {
|
||||
return const AuthResult(false, 'Google sign-in was cancelled.');
|
||||
}
|
||||
final auth = await account.authentication;
|
||||
final idToken = auth.idToken;
|
||||
if (idToken == null || idToken.isEmpty) {
|
||||
return const AuthResult(
|
||||
false,
|
||||
'Google did not return an ID token. Check Android OAuth client config.',
|
||||
);
|
||||
}
|
||||
return _exchangeGoogleIdToken(idToken);
|
||||
} catch (e) {
|
||||
return AuthResult(false, 'Google sign-in failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +303,27 @@ class _UploadPageState extends State<UploadPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _signOut() async {
|
||||
final refreshToken = _refreshToken;
|
||||
try {
|
||||
if (refreshToken.isNotEmpty) {
|
||||
await http.post(
|
||||
Uri.parse('$brokerUrl/auth/logout'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'refresh_token': refreshToken}),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Local sign-out should still complete.
|
||||
}
|
||||
try {
|
||||
await _googleSignIn.signOut();
|
||||
} catch (_) {
|
||||
// Google session may already be absent.
|
||||
}
|
||||
await _clearBrokerSession();
|
||||
}
|
||||
|
||||
Future<void> _loadDirectUploadConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cfg = DirectUploadConfig(
|
||||
@@ -208,6 +340,19 @@ class _UploadPageState extends State<UploadPage> {
|
||||
|
||||
Future<void> _saveDirectUploadConfig() async {
|
||||
final token = _tokenController.text.trim();
|
||||
if (token.isEmpty) return;
|
||||
final registered = await _registerPairingTokenWithBroker(token);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
registered
|
||||
? 'Pairing token registered with broker.'
|
||||
: 'Could not register pairing token with broker.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('direct_upload_enabled', true);
|
||||
await prefs.remove('direct_upload_endpoint');
|
||||
@@ -219,9 +364,39 @@ class _UploadPageState extends State<UploadPage> {
|
||||
endpoint: '',
|
||||
token: token,
|
||||
);
|
||||
_editingPairing = false;
|
||||
});
|
||||
}
|
||||
|
||||
bool get _hasBrokerSession =>
|
||||
_accessToken.isNotEmpty || _refreshToken.isNotEmpty;
|
||||
|
||||
bool get _hasSavedPairingToken => _directConfig.token.trim().isNotEmpty;
|
||||
|
||||
bool get _pairingInputChanged =>
|
||||
_tokenController.text.trim() != _directConfig.token.trim();
|
||||
|
||||
Future<bool> _registerPairingTokenWithBroker(String pairingToken) async {
|
||||
try {
|
||||
final accessToken = await _getValidAccessToken();
|
||||
if (accessToken == null) return false;
|
||||
final res = await http.post(
|
||||
Uri.parse('$brokerUrl/agents/pairing-token'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'pairing_token': pairingToken,
|
||||
'name': 'Primary Agent',
|
||||
}),
|
||||
);
|
||||
return res.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickPhotos() async {
|
||||
final picked = await _picker.pickMultiImage();
|
||||
if (picked.isEmpty) return;
|
||||
@@ -479,7 +654,10 @@ class _UploadPageState extends State<UploadPage> {
|
||||
final token = await _getValidAccessToken();
|
||||
if (token == null) {
|
||||
if (mounted) {
|
||||
setState(() => _agentConnected = false);
|
||||
setState(() {
|
||||
_agentConnected = false;
|
||||
_agentLanEndpoint = '';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -489,18 +667,37 @@ class _UploadPageState extends State<UploadPage> {
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
if (mounted) {
|
||||
setState(() => _agentConnected = false);
|
||||
setState(() {
|
||||
_agentConnected = false;
|
||||
_agentLanEndpoint = '';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final body = jsonDecode(res.body);
|
||||
final connected = _extractAgentConnected(body);
|
||||
var endpoint = '';
|
||||
if (body is Map<String, dynamic> && body['lan_endpoints'] is List) {
|
||||
for (final raw in body['lan_endpoints'] as List) {
|
||||
final value = raw.toString().trim();
|
||||
if (value.isNotEmpty) {
|
||||
endpoint = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _agentConnected = connected);
|
||||
setState(() {
|
||||
_agentConnected = connected;
|
||||
_agentLanEndpoint = endpoint;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _agentConnected = false);
|
||||
setState(() {
|
||||
_agentConnected = false;
|
||||
_agentLanEndpoint = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,6 +765,7 @@ class _UploadPageState extends State<UploadPage> {
|
||||
final encoded = Uri.encodeComponent(photo.filename!);
|
||||
directUrl = hints.first.resolve('/api/photo/$encoded').toString();
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
@@ -701,6 +899,19 @@ class _UploadPageState extends State<UploadPage> {
|
||||
}
|
||||
|
||||
Widget _buildDirectUploadSettings() {
|
||||
final signedIn = _hasBrokerSession;
|
||||
final paired = _hasSavedPairingToken;
|
||||
final canSavePairing =
|
||||
signedIn &&
|
||||
_tokenController.text.trim().isNotEmpty &&
|
||||
(!paired || _editingPairing) &&
|
||||
_pairingInputChanged;
|
||||
final accountLabel = _userDisplayName.trim().isNotEmpty
|
||||
? _userDisplayName.trim()
|
||||
: _userEmail.trim().isNotEmpty
|
||||
? _userEmail.trim()
|
||||
: 'Signed in with Google';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Padding(
|
||||
@@ -708,59 +919,128 @@ class _UploadPageState extends State<UploadPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_accessToken.isEmpty
|
||||
? 'Broker session: not signed in'
|
||||
: 'Broker session: signed in',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _googleIdTokenController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Google ID token',
|
||||
helperText: 'Paste Google ID token, then sign in.',
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
final ok = await _signInWithGoogleIdToken();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ok ? 'Broker sign-in successful.' : 'Broker sign-in failed.',
|
||||
Row(
|
||||
children: [
|
||||
if (signedIn && _userPictureUrl.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundImage: NetworkImage(_userPictureUrl),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
signedIn ? Icons.verified_user : Icons.account_circle,
|
||||
size: 18,
|
||||
color: signedIn ? const Color(0xFF0F766E) : Colors.black54,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
signedIn ? accountLabel : 'Not signed in',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (ok) {
|
||||
await _loadGallery();
|
||||
}
|
||||
},
|
||||
child: const Text('Sign In'),
|
||||
if (signedIn &&
|
||||
_userEmail.isNotEmpty &&
|
||||
_userEmail != accountLabel)
|
||||
Text(
|
||||
_userEmail,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (signedIn)
|
||||
TextButton(onPressed: _signOut, child: const Text('Sign Out'))
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final result = await _signInWithGoogle();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(result.message)));
|
||||
if (result.ok) {
|
||||
await _loadGallery();
|
||||
}
|
||||
},
|
||||
child: const Text('Sign In'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
paired ? Icons.lan : Icons.link_off,
|
||||
size: 18,
|
||||
color: paired ? const Color(0xFF0F766E) : Colors.black54,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
paired
|
||||
? _agentConnected
|
||||
? 'Agent paired and connected'
|
||||
: 'Agent paired, waiting for connection'
|
||||
: 'Agent not paired',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (_agentLanEndpoint.isNotEmpty)
|
||||
Text(
|
||||
_agentLanEndpoint,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!signedIn || !paired) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
!signedIn
|
||||
? 'Sign in before pairing this phone with your agent.'
|
||||
: 'Enter the pairing token from your agent.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
const Divider(height: 20),
|
||||
const Text(
|
||||
'Direct upload is automatic using broker-provided agent LAN hints. '
|
||||
'If direct fails, upload falls back to broker.',
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _tokenController,
|
||||
decoration: const InputDecoration(labelText: 'Pairing token'),
|
||||
enabled: signedIn && (!paired || _editingPairing),
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Pairing token',
|
||||
helperText: paired
|
||||
? 'Saved. Change the token only when re-pairing.'
|
||||
: 'Required once for direct LAN upload.',
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
onPressed: _saveDirectUploadConfig,
|
||||
child: const Text('Save LAN Settings'),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (paired && !_editingPairing)
|
||||
TextButton(
|
||||
onPressed: signedIn
|
||||
? () => setState(() => _editingPairing = true)
|
||||
: null,
|
||||
child: const Text('Change'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: canSavePairing ? _saveDirectUploadConfig : null,
|
||||
child: Text(
|
||||
paired ? 'LAN Settings Saved' : 'Save LAN Settings',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user