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:
2026-05-21 22:37:01 +08:00
parent 2429c53985
commit 08cac42890
13 changed files with 856 additions and 180 deletions

View File

@@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
android.enableJetifier=false

View File

@@ -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',
),
),
],
),
],
),

View File

@@ -6,9 +6,11 @@ import FlutterMacOS
import Foundation
import file_selector_macos
import google_sign_in_ios
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View File

@@ -37,10 +37,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -184,6 +184,54 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_sign_in:
dependency: "direct main"
description:
name: google_sign_in
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
url: "https://pub.dev"
source: hosted
version: "6.2.1"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
url: "https://pub.dev"
source: hosted
version: "5.9.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
url: "https://pub.dev"
source: hosted
version: "0.12.4+4"
http:
dependency: "direct main"
description:
@@ -284,26 +332,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -316,26 +364,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -513,10 +561,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.8"
typed_data:
dependency: transitive
description:
@@ -529,10 +577,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@@ -574,5 +622,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.32.0"

View File

@@ -39,6 +39,7 @@ dependencies:
image: ^4.2.0
path: ^1.9.0
shared_preferences: ^2.3.3
google_sign_in: ^6.2.2
dev_dependencies:
flutter_test:

View File

@@ -5,26 +5,17 @@
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:memory_by_clouderized/main.dart';
import 'package:memoric/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
testWidgets('MemoriC app renders', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
await tester.pumpWidget(const MemoryApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.text('Memory by Clouderized'), findsOneWidget);
expect(find.text('Gallery'), findsOneWidget);
expect(find.text('Uploads'), findsOneWidget);
});
}