Grain flutter app

feat: Refactor session management and update API calls to use new session model

+401 -159
+128 -108
lib/api.dart
··· 2 2 import 'dart:io'; 3 3 4 4 import 'package:at_uri/at_uri.dart'; 5 - import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 6 5 import 'package:grain/app_logger.dart'; 7 6 import 'package:grain/dpop_client.dart'; 8 7 import 'package:grain/main.dart'; 9 - import 'package:grain/models/atproto_session.dart'; 8 + import 'package:grain/models/session.dart'; 10 9 import 'package:grain/photo_manip.dart'; 11 10 import 'package:http/http.dart' as http; 12 - import 'package:jose/jose.dart'; 13 11 import 'package:mime/mime.dart'; 14 12 15 13 import './auth.dart'; ··· 23 21 import 'models/profile.dart'; 24 22 25 23 class ApiService { 26 - static const _storage = FlutterSecureStorage(); 27 - String? _accessToken; 28 24 Profile? currentUser; 29 25 Profile? loadedProfile; 30 26 List<Gallery> galleries = []; 31 27 32 28 String get _apiUrl => AppConfig.apiUrl; 33 - String? getAccessToken() => _accessToken; 34 29 35 - Future<void> loadToken() async { 36 - _accessToken = await _storage.read(key: 'access_token'); 37 - } 38 - 39 - Future<void> setToken(String? token) async { 40 - _accessToken = token; 41 - if (token != null) { 42 - await _storage.write(key: 'access_token', value: token); 43 - } else { 44 - await _storage.delete(key: 'access_token'); 30 + Future<Session?> fetchSession([String? initialToken]) async { 31 + String? token = initialToken; 32 + if (token == null) { 33 + final session = await auth.getValidSession(); 34 + token = session?.token; 35 + if (token == null) return null; 45 36 } 46 - } 47 - 48 - bool get hasToken => _accessToken != null && _accessToken!.isNotEmpty; 49 - 50 - Future<AtprotoSession?> fetchSession() async { 51 - if (_accessToken == null) return null; 52 37 53 38 final response = await http.get( 54 39 Uri.parse('$_apiUrl/oauth/session'), 55 - headers: {'Authorization': 'Bearer $_accessToken', 'Content-Type': 'application/json'}, 40 + headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'}, 56 41 ); 57 42 58 43 if (response.statusCode != 200) { 59 44 throw Exception('Failed to fetch session'); 60 45 } 61 46 62 - final json = jsonDecode(response.body); 63 - final token = json['tokenSet'] ?? {}; 64 - return AtprotoSession( 65 - accessToken: token['access_token'] as String, 66 - tokenType: token['token_type'] as String, 67 - expiresAt: DateTime.parse(token['expires_at'] as String), 68 - dpopJwk: JsonWebKey.fromJson(json['dpopJwk'] as Map<String, dynamic>), 69 - issuer: token['iss'] as String, 70 - subject: token['sub'] as String, 71 - ); 47 + return Session.fromJson(jsonDecode(response.body)); 48 + } 49 + 50 + Future<bool> revokeSession() async { 51 + final session = await auth.getValidSession(); 52 + final token = session?.token; 53 + if (token == null) { 54 + appLogger.w('No access token for revokeSession'); 55 + return false; 56 + } 57 + final url = Uri.parse('$_apiUrl/oauth/revoke'); 58 + final headers = {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'}; 59 + try { 60 + final response = await http.post(url, headers: headers); 61 + if (response.statusCode == 200) { 62 + appLogger.i('Session revoked successfully'); 63 + return true; 64 + } else { 65 + appLogger.w('Failed to revoke session: ${response.statusCode} ${response.body}'); 66 + return false; 67 + } 68 + } catch (e) { 69 + appLogger.e('Error revoking session: $e'); 70 + return false; 71 + } 72 72 } 73 73 74 74 Future<Profile?> fetchCurrentUser() async { 75 75 final session = await auth.getValidSession(); 76 76 77 - if (session == null || session.subject.isEmpty) { 77 + if (session == null || session.session.subject.isEmpty) { 78 78 return null; 79 79 } 80 80 81 - final user = await fetchProfile(did: session.subject); 81 + final user = await fetchProfile(did: session.session.subject); 82 82 83 83 currentUser = user; 84 84 ··· 86 86 } 87 87 88 88 Future<Profile?> fetchProfile({required String did}) async { 89 + final session = await auth.getValidSession(); 90 + final token = session?.token; 89 91 appLogger.i('Fetching profile for did: $did'); 90 92 final response = await http.get( 91 93 Uri.parse('$_apiUrl/xrpc/social.grain.actor.getProfile?actor=$did'), 92 - headers: {'Content-Type': 'application/json', 'Authorization': "Bearer $_accessToken"}, 94 + headers: {'Content-Type': 'application/json', 'Authorization': "Bearer $token"}, 93 95 ); 94 96 if (response.statusCode != 200) { 95 97 appLogger.w('Failed to fetch profile: ${response.statusCode} ${response.body}'); ··· 130 132 } 131 133 132 134 Future<List<Gallery>> getTimeline({String? algorithm}) async { 133 - if (_accessToken == null) { 135 + final session = await auth.getValidSession(); 136 + final token = session?.token; 137 + if (token == null) { 134 138 return []; 135 139 } 136 - appLogger.i('Fetching timeline with algorithm: \\${algorithm ?? 'default'}'); 140 + appLogger.i('Fetching timeline with algorithm: ${algorithm ?? 'default'}'); 137 141 final uri = algorithm != null 138 142 ? Uri.parse('$_apiUrl/xrpc/social.grain.feed.getTimeline?algorithm=$algorithm') 139 143 : Uri.parse('$_apiUrl/xrpc/social.grain.feed.getTimeline'); 140 144 final response = await http.get( 141 145 uri, 142 - headers: {'Authorization': "Bearer $_accessToken", 'Content-Type': 'application/json'}, 146 + headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 143 147 ); 144 148 if (response.statusCode != 200) { 145 149 appLogger.w('Failed to fetch timeline: ${response.statusCode} ${response.body}'); ··· 154 158 155 159 Future<Gallery?> getGallery({required String uri}) async { 156 160 appLogger.i('Fetching gallery for uri: $uri'); 161 + final session = await auth.getValidSession(); 162 + final token = session?.token; 163 + if (token == null) { 164 + appLogger.w('No access token for getGallery'); 165 + return null; 166 + } 157 167 final response = await http.get( 158 168 Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGallery?uri=$uri'), 159 - headers: {'Authorization': "Bearer $_accessToken", 'Content-Type': 'application/json'}, 169 + headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 160 170 ); 161 171 if (response.statusCode != 200) { 162 172 appLogger.w('Failed to fetch gallery: ${response.statusCode} ${response.body}'); ··· 178 188 179 189 Future<GalleryThread?> getGalleryThread({required String uri}) async { 180 190 appLogger.i('Fetching gallery thread for uri: $uri'); 191 + final session = await auth.getValidSession(); 192 + final token = session?.token; 193 + if (token == null) { 194 + appLogger.w('No access token for getGalleryThread'); 195 + return null; 196 + } 181 197 final response = await http.get( 182 198 Uri.parse('$_apiUrl/xrpc/social.grain.gallery.getGalleryThread?uri=$uri'), 183 - headers: {'Content-Type': 'application/json', 'Authorization': "Bearer $_accessToken"}, 199 + headers: {'Content-Type': 'application/json', 'Authorization': "Bearer $token"}, 184 200 ); 185 201 if (response.statusCode != 200) { 186 - appLogger.w('Failed to fetch gallery thread: \\${response.statusCode} \\${response.body}'); 202 + appLogger.w('Failed to fetch gallery thread: ${response.statusCode} ${response.body}'); 187 203 return null; 188 204 } 189 205 final json = jsonDecode(response.body) as Map<String, dynamic>; ··· 191 207 } 192 208 193 209 Future<List<grain.Notification>> getNotifications() async { 194 - if (_accessToken == null) { 210 + final session = await auth.getValidSession(); 211 + final token = session?.token; 212 + if (token == null) { 195 213 appLogger.w('No access token for getNotifications'); 196 214 return []; 197 215 } 198 216 appLogger.i('Fetching notifications'); 199 217 final response = await http.get( 200 218 Uri.parse('$_apiUrl/xrpc/social.grain.notification.getNotifications'), 201 - headers: {'Authorization': "Bearer $_accessToken", 'Content-Type': 'application/json'}, 219 + headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 202 220 ); 203 221 if (response.statusCode != 200) { 204 - appLogger.w('Failed to fetch notifications: \\${response.statusCode} \\${response.body}'); 222 + appLogger.w('Failed to fetch notifications: ${response.statusCode} ${response.body}'); 205 223 return []; 206 224 } 207 225 final json = jsonDecode(response.body); ··· 238 256 } 239 257 240 258 Future<List<Profile>> searchActors(String query) async { 241 - if (_accessToken == null) { 259 + final session = await auth.getValidSession(); 260 + final token = session?.token; 261 + if (token == null) { 242 262 appLogger.w('No access token for searchActors'); 243 263 return []; 244 264 } 245 265 appLogger.i('Searching actors with query: $query'); 246 266 final response = await http.get( 247 267 Uri.parse('$_apiUrl/xrpc/social.grain.actor.searchActors?q=$query'), 248 - headers: {'Authorization': "Bearer $_accessToken", 'Content-Type': 'application/json'}, 268 + headers: {'Authorization': "Bearer $token", 'Content-Type': 'application/json'}, 249 269 ); 250 270 if (response.statusCode != 200) { 251 271 appLogger.w('Failed to search actors: ${response.statusCode} ${response.body}'); ··· 279 299 appLogger.w('No valid session for createGallery'); 280 300 return null; 281 301 } 282 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 283 - final issuer = session.issuer; 284 - final did = session.subject; 302 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 303 + final issuer = session.session.issuer; 304 + final did = session.session.subject; 285 305 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 286 306 final record = { 287 307 'collection': 'social.grain.gallery', ··· 298 318 final response = await dpopClient.send( 299 319 method: 'POST', 300 320 url: url, 301 - accessToken: session.accessToken, 321 + accessToken: session.session.accessToken, 302 322 headers: {'Content-Type': 'application/json'}, 303 323 body: jsonEncode(record), 304 324 ); ··· 370 390 appLogger.w('No valid session for uploadBlob'); 371 391 return null; 372 392 } 373 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 374 - final issuer = session.issuer; 393 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 394 + final issuer = session.session.issuer; 375 395 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.uploadBlob'); 376 396 377 397 // Detect MIME type, fallback to application/octet-stream if unknown ··· 385 405 final response = await dpopClient.send( 386 406 method: 'POST', 387 407 url: url, 388 - accessToken: session.accessToken, 408 + accessToken: session.session.accessToken, 389 409 headers: {'Content-Type': contentType}, 390 410 body: bytes, 391 411 ); ··· 418 438 appLogger.w('No valid session for createPhotoRecord'); 419 439 return null; 420 440 } 421 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 422 - final issuer = session.issuer; 423 - final did = session.subject; 441 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 442 + final issuer = session.session.issuer; 443 + final did = session.session.subject; 424 444 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 425 445 final record = { 426 446 'collection': 'social.grain.photo', ··· 436 456 final response = await dpopClient.send( 437 457 method: 'POST', 438 458 url: url, 439 - accessToken: session.accessToken, 459 + accessToken: session.session.accessToken, 440 460 headers: {'Content-Type': 'application/json'}, 441 461 body: jsonEncode(record), 442 462 ); ··· 459 479 appLogger.w('No valid session for createGalleryItem'); 460 480 return null; 461 481 } 462 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 463 - final issuer = session.issuer; 464 - final did = session.subject; 482 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 483 + final issuer = session.session.issuer; 484 + final did = session.session.subject; 465 485 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 466 486 final record = { 467 487 'collection': 'social.grain.gallery.item', ··· 477 497 final response = await dpopClient.send( 478 498 method: 'POST', 479 499 url: url, 480 - accessToken: session.accessToken, 500 + accessToken: session.session.accessToken, 481 501 headers: {'Content-Type': 'application/json'}, 482 502 body: jsonEncode(record), 483 503 ); ··· 502 522 appLogger.w('No valid session for createComment'); 503 523 return null; 504 524 } 505 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 506 - final issuer = session.issuer; 507 - final did = session.subject; 525 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 526 + final issuer = session.session.issuer; 527 + final did = session.session.subject; 508 528 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 509 529 final record = { 510 530 'collection': 'social.grain.comment', ··· 522 542 final response = await dpopClient.send( 523 543 method: 'POST', 524 544 url: url, 525 - accessToken: session.accessToken, 545 + accessToken: session.session.accessToken, 526 546 headers: {'Content-Type': 'application/json'}, 527 547 body: jsonEncode(record), 528 548 ); ··· 541 561 appLogger.w('No valid session for createFavorite'); 542 562 return null; 543 563 } 544 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 545 - final issuer = session.issuer; 546 - final did = session.subject; 564 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 565 + final issuer = session.session.issuer; 566 + final did = session.session.subject; 547 567 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 548 568 final record = { 549 569 'collection': 'social.grain.favorite', ··· 554 574 final response = await dpopClient.send( 555 575 method: 'POST', 556 576 url: url, 557 - accessToken: session.accessToken, 577 + accessToken: session.session.accessToken, 558 578 headers: {'Content-Type': 'application/json'}, 559 579 body: jsonEncode(record), 560 580 ); ··· 573 593 appLogger.w('No valid session for createFollow'); 574 594 return null; 575 595 } 576 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 577 - final issuer = session.issuer; 578 - final did = session.subject; 596 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 597 + final issuer = session.session.issuer; 598 + final did = session.session.subject; 579 599 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 580 600 final record = { 581 601 'collection': 'social.grain.graph.follow', ··· 586 606 final response = await dpopClient.send( 587 607 method: 'POST', 588 608 url: url, 589 - accessToken: session.accessToken, 609 + accessToken: session.session.accessToken, 590 610 headers: {'Content-Type': 'application/json'}, 591 611 body: jsonEncode(record), 592 612 ); ··· 607 627 appLogger.w('No valid session for deleteRecord'); 608 628 return false; 609 629 } 610 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 611 - final issuer = session.issuer; 630 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 631 + final issuer = session.session.issuer; 612 632 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.deleteRecord'); 613 - final repo = session.subject; 633 + final repo = session.session.subject; 614 634 if (repo.isEmpty) { 615 635 appLogger.w('No repo (DID) available from session for deleteRecord'); 616 636 return false; ··· 633 653 final response = await dpopClient.send( 634 654 method: 'POST', 635 655 url: url, 636 - accessToken: session.accessToken, 656 + accessToken: session.session.accessToken, 637 657 headers: {'Content-Type': 'application/json'}, 638 658 body: jsonEncode(payload), 639 659 ); ··· 658 678 appLogger.w('No valid session for updateProfile'); 659 679 return false; 660 680 } 661 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 662 - final issuer = session.issuer; 663 - final did = session.subject; 681 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 682 + final issuer = session.session.issuer; 683 + final did = session.session.subject; 664 684 // Fetch the raw profile record from atproto getRecord endpoint 665 685 final getUrl = Uri.parse( 666 686 '$issuer/xrpc/com.atproto.repo.getRecord?repo=$did&collection=social.grain.actor.profile&rkey=self', ··· 668 688 final getResp = await dpopClient.send( 669 689 method: 'GET', 670 690 url: getUrl, 671 - accessToken: session.accessToken, 691 + accessToken: session.session.accessToken, 672 692 headers: {'Content-Type': 'application/json'}, 673 693 ); 674 694 if (getResp.statusCode != 200) { ··· 704 724 final response = await dpopClient.send( 705 725 method: 'POST', 706 726 url: url, 707 - accessToken: session.accessToken, 727 + accessToken: session.session.accessToken, 708 728 headers: {'Content-Type': 'application/json'}, 709 729 body: jsonEncode(record), 710 730 ); ··· 760 780 appLogger.w('No valid session for updateGallerySortOrder'); 761 781 return false; 762 782 } 763 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 764 - final issuer = session.issuer; 765 - final did = session.subject; 783 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 784 + final issuer = session.session.issuer; 785 + final did = session.session.subject; 766 786 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.applyWrites'); 767 787 768 788 final updates = <Map<String, dynamic>>[]; ··· 794 814 final response = await dpopClient.send( 795 815 method: 'POST', 796 816 url: url, 797 - accessToken: session.accessToken, 817 + accessToken: session.session.accessToken, 798 818 headers: {'Content-Type': 'application/json'}, 799 819 body: jsonEncode(payload), 800 820 ); ··· 822 842 appLogger.w('No valid session for updateGallery'); 823 843 return false; 824 844 } 825 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 826 - final issuer = session.issuer; 827 - final did = session.subject; 845 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 846 + final issuer = session.session.issuer; 847 + final did = session.session.subject; 828 848 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.putRecord'); 829 849 // Extract rkey from galleryUri 830 850 String rkey = ''; ··· 851 871 final response = await dpopClient.send( 852 872 method: 'POST', 853 873 url: url, 854 - accessToken: session.accessToken, 874 + accessToken: session.session.accessToken, 855 875 headers: {'Content-Type': 'application/json'}, 856 876 body: jsonEncode(record), 857 877 ); ··· 884 904 appLogger.w('No valid session for createPhotoExif'); 885 905 return null; 886 906 } 887 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 888 - final issuer = session.issuer; 889 - final did = session.subject; 907 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 908 + final issuer = session.session.issuer; 909 + final did = session.session.subject; 890 910 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.createRecord'); 891 911 final record = { 892 912 'collection': 'social.grain.photo.exif', ··· 910 930 final response = await dpopClient.send( 911 931 method: 'POST', 912 932 url: url, 913 - accessToken: session.accessToken, 933 + accessToken: session.session.accessToken, 914 934 headers: {'Content-Type': 'application/json'}, 915 935 body: jsonEncode(record), 916 936 ); ··· 934 954 appLogger.w('No valid session for updatePhotosBatch'); 935 955 return false; 936 956 } 937 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 938 - final issuer = session.issuer; 939 - final did = session.subject; 957 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 958 + final issuer = session.session.issuer; 959 + final did = session.session.subject; 940 960 final url = Uri.parse('$issuer/xrpc/com.atproto.repo.applyWrites'); 941 961 942 962 // Fetch current photo records for all photos ··· 991 1011 final response = await dpopClient.send( 992 1012 method: 'POST', 993 1013 url: url, 994 - accessToken: session.accessToken, 1014 + accessToken: session.session.accessToken, 995 1015 headers: {'Content-Type': 'application/json'}, 996 1016 body: jsonEncode(payload), 997 1017 ); ··· 1011 1031 appLogger.w('No valid session for fetchPhotoRecords'); 1012 1032 return {}; 1013 1033 } 1014 - final dpopClient = DpopHttpClient(dpopKey: session.dpopJwk); 1015 - final issuer = session.issuer; 1016 - final did = session.subject; 1034 + final dpopClient = DpopHttpClient(dpopKey: session.session.dpopJwk); 1035 + final issuer = session.session.issuer; 1036 + final did = session.session.subject; 1017 1037 final url = Uri.parse( 1018 1038 '$issuer/xrpc/com.atproto.repo.listRecords?repo=$did&collection=social.grain.photo', 1019 1039 ); ··· 1021 1041 final response = await dpopClient.send( 1022 1042 method: 'GET', 1023 1043 url: url, 1024 - accessToken: session.accessToken, 1044 + accessToken: session.session.accessToken, 1025 1045 headers: {'Content-Type': 'application/json'}, 1026 1046 ); 1027 1047 ··· 1047 1067 /// Notifies the server that the requesting account has seen notifications. 1048 1068 /// Sends a POST request with the current ISO timestamp as seenAt. 1049 1069 Future<bool> updateSeen() async { 1050 - if (_accessToken == null) { 1070 + final session = await auth.getValidSession(); 1071 + final token = session?.token; 1072 + if (token == null) { 1051 1073 appLogger.w('No access token for updateSeen'); 1052 1074 return false; 1053 1075 } 1054 1076 final url = Uri.parse('$_apiUrl/xrpc/social.grain.notification.updateSeen'); 1055 1077 final seenAt = DateTime.now().toUtc().toIso8601String(); 1056 1078 final body = jsonEncode({'seenAt': seenAt}); 1057 - final headers = {'Authorization': 'Bearer $_accessToken', 'Content-Type': 'application/json'}; 1079 + final headers = {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'}; 1058 1080 try { 1059 1081 final response = await http.post(url, headers: headers, body: body); 1060 1082 if (response.statusCode == 200) { 1061 1083 appLogger.i('Successfully updated seen notifications at $seenAt'); 1062 1084 return true; 1063 1085 } else { 1064 - appLogger.w( 1065 - 'Failed to update seen notifications: \\${response.statusCode} \\${response.body}', 1066 - ); 1086 + appLogger.w('Failed to update seen notifications: ${response.statusCode} ${response.body}'); 1067 1087 return false; 1068 1088 } 1069 1089 } catch (e) {
+33 -23
lib/auth.dart
··· 6 6 import 'package:grain/app_logger.dart'; 7 7 import 'package:grain/main.dart'; 8 8 import 'package:grain/models/atproto_session.dart'; 9 + import 'package:grain/models/session.dart'; 9 10 10 11 class Auth { 11 12 static const _storage = FlutterSecureStorage(); 12 13 Auth(); 14 + 15 + Future<bool> hasToken() async { 16 + final session = await _loadSession(); 17 + return session != null && session.token.isNotEmpty && !isSessionExpired(session.session); 18 + } 13 19 14 20 Future<void> login(String handle) async { 15 21 final apiUrl = AppConfig.apiUrl; ··· 23 29 appLogger.i('Redirected URL: $redirectedUrl'); 24 30 appLogger.i('User signed in with handle: $handle'); 25 31 26 - apiService.setToken(token); 27 - 28 - final session = await apiService.fetchSession(); 32 + final session = await apiService.fetchSession(token); 29 33 if (session == null) { 30 34 throw Exception('Failed to fetch session after login'); 31 35 } 32 - 33 36 await _saveSession(session); 34 37 } 35 38 36 - Future<void> _saveSession(AtprotoSession session) async { 37 - final sessionJson = jsonEncode(session.toJson()); 38 - await _storage.write(key: 'atproto_session', value: sessionJson); 39 + Future<void> _saveSession(Session session) async { 40 + final atprotoSessionJson = jsonEncode(session.session.toJson()); 41 + await _storage.write(key: 'atproto_session', value: atprotoSessionJson); 42 + await _storage.write(key: 'api_token', value: session.token); 39 43 } 40 44 41 - Future<AtprotoSession?> _loadSession() async { 42 - final jsonString = await _storage.read(key: 'atproto_session'); 43 - if (jsonString == null) return null; 45 + Future<Session?> _loadSession() async { 46 + final sessionJsonString = await _storage.read(key: 'atproto_session'); 47 + final token = await _storage.read(key: 'api_token'); 48 + if (sessionJsonString == null || token == null) return null; 44 49 45 50 try { 46 - final json = jsonDecode(jsonString); 47 - return AtprotoSession.fromJson(json); 51 + final sessionJson = jsonDecode(sessionJsonString); 52 + return Session(session: AtprotoSession.fromJson(sessionJson), token: token); 48 53 } catch (e) { 49 54 // Optionally log or clear storage if corrupted 50 55 return null; ··· 59 64 return session.expiresAt.subtract(tolerance).isBefore(now); 60 65 } 61 66 62 - Future<AtprotoSession?> getValidSession() async { 63 - var session = await _loadSession(); 64 - if (session == null || isSessionExpired(session)) { 65 - appLogger.w('Session is expired or not found, attempting refresh'); 66 - // Try to refresh session by calling fetchSession 67 + Future<Session?> getValidSession() async { 68 + final session = await _loadSession(); 69 + if (session == null) { 70 + // No session at all, do not attempt refresh 71 + return null; 72 + } 73 + if (isSessionExpired(session.session)) { 74 + appLogger.w('Session is expired, attempting refresh'); 67 75 try { 68 76 final refreshed = await apiService.fetchSession(); 69 - if (refreshed != null && !isSessionExpired(refreshed)) { 77 + if (refreshed != null && !isSessionExpired(refreshed.session)) { 70 78 await _saveSession(refreshed); 71 79 appLogger.i('Session refreshed and saved'); 72 80 return refreshed; 73 81 } else { 74 - appLogger.w('Session refresh failed or still expired'); 82 + appLogger.w('Session refresh failed or still expired, clearing session'); 83 + await clearSession(); 75 84 return null; 76 85 } 77 86 } catch (e) { 78 87 appLogger.e('Error refreshing session: $e'); 88 + await clearSession(); 79 89 return null; 80 90 } 81 91 } ··· 83 93 } 84 94 85 95 Future<void> clearSession() async { 96 + // Revoke session on the server 97 + await apiService.revokeSession(); 86 98 // Remove session from secure storage 87 99 await _storage.delete(key: 'atproto_session'); 88 - // Remove access token from secure storage and memory 89 - await apiService.setToken(null); 90 - // Optionally clear any in-memory session/user data 100 + await _storage.delete(key: 'api_token'); 101 + // Clear any in-memory session/user data 91 102 apiService.currentUser = null; 92 - // If you add a session property to ApiService, clear it here as well 93 103 } 94 104 } 95 105
+10 -26
lib/main.dart
··· 41 41 appLogger.e('Flutter error: ${details.exception}\n${details.stack}'); 42 42 }; 43 43 await AppConfig.init(); 44 - await apiService.loadToken(); // Restore access token before app starts 45 44 appLogger.i('🚀 App started'); 46 45 runApp(const ProviderScope(child: MyApp())); 47 46 } ··· 72 71 super.dispose(); 73 72 } 74 73 75 - void _connectWebSocket() { 74 + Future<void> _connectWebSocket() async { 75 + if (_wsService != null) return; // Already connected 76 76 _disconnectWebSocket(); 77 77 if (!isSignedIn) return; 78 - final accessToken = apiService.getAccessToken(); 79 - if (accessToken == null) return; 78 + final session = await auth.getValidSession(); 79 + if (session == null) return; 80 80 _wsService = WebSocketService( 81 81 wsUrl: AppConfig.wsUrl, 82 - accessToken: accessToken, 82 + accessToken: session.token, 83 83 onMessage: (message) { 84 84 // Optionally: handle global messages or trigger provider updates 85 85 }, ··· 95 95 @override 96 96 void didChangeAppLifecycleState(AppLifecycleState state) { 97 97 if (state == AppLifecycleState.resumed && isSignedIn) { 98 + // ignore: unawaited_futures 98 99 _connectWebSocket(); 99 100 } else if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { 100 101 _disconnectWebSocket(); ··· 102 103 } 103 104 104 105 Future<void> _checkToken() async { 105 - await apiService.loadToken(); 106 - bool valid = false; 107 - if (apiService.hasToken) { 108 - try { 109 - final session = await apiService.fetchSession(); 110 - if (session != null) { 111 - await apiService.fetchCurrentUser(); 112 - valid = true; 113 - } else { 114 - await auth.clearSession(); 115 - } 116 - } catch (e) { 117 - await auth.clearSession(); 118 - } 119 - } 106 + final user = await apiService.fetchCurrentUser(); 107 + final valid = user != null; 120 108 setState(() { 121 109 isSignedIn = valid; 122 110 _loading = false; 123 111 }); 124 - if (valid) { 125 - _connectWebSocket(); 126 - } 127 112 } 128 113 129 - // Invalidate providers to refresh data after sign in/sign out 114 + // Invalidate providers to refresh data 130 115 void _invalidateProviders() { 131 116 final container = ProviderScope.containerOf(context, listen: false); 132 117 container.invalidate(profileNotifierProvider); ··· 139 124 }); 140 125 appLogger.i('Fetching current user after sign in'); 141 126 await apiService.fetchCurrentUser(); 127 + await _connectWebSocket(); 142 128 _invalidateProviders(); 143 - _connectWebSocket(); 144 129 } 145 130 146 131 void _handleSignOut(BuildContext context) async { 147 - _invalidateProviders(); 148 132 await auth.clearSession(); 149 133 setState(() { 150 134 isSignedIn = false;
+13
lib/models/session.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + import 'atproto_session.dart'; 4 + 5 + part 'session.freezed.dart'; 6 + part 'session.g.dart'; 7 + 8 + @freezed 9 + class Session with _$Session { 10 + const factory Session({required AtprotoSession session, required String token}) = _Session; 11 + 12 + factory Session.fromJson(Map<String, dynamic> json) => _$SessionFromJson(json); 13 + }
+194
lib/models/session.freezed.dart
··· 1 + // coverage:ignore-file 2 + // GENERATED CODE - DO NOT MODIFY BY HAND 3 + // ignore_for_file: type=lint 4 + // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 + 6 + part of 'session.dart'; 7 + 8 + // ************************************************************************** 9 + // FreezedGenerator 10 + // ************************************************************************** 11 + 12 + T _$identity<T>(T value) => value; 13 + 14 + final _privateConstructorUsedError = UnsupportedError( 15 + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', 16 + ); 17 + 18 + Session _$SessionFromJson(Map<String, dynamic> json) { 19 + return _Session.fromJson(json); 20 + } 21 + 22 + /// @nodoc 23 + mixin _$Session { 24 + AtprotoSession get session => throw _privateConstructorUsedError; 25 + String get token => throw _privateConstructorUsedError; 26 + 27 + /// Serializes this Session to a JSON map. 28 + Map<String, dynamic> toJson() => throw _privateConstructorUsedError; 29 + 30 + /// Create a copy of Session 31 + /// with the given fields replaced by the non-null parameter values. 32 + @JsonKey(includeFromJson: false, includeToJson: false) 33 + $SessionCopyWith<Session> get copyWith => throw _privateConstructorUsedError; 34 + } 35 + 36 + /// @nodoc 37 + abstract class $SessionCopyWith<$Res> { 38 + factory $SessionCopyWith(Session value, $Res Function(Session) then) = 39 + _$SessionCopyWithImpl<$Res, Session>; 40 + @useResult 41 + $Res call({AtprotoSession session, String token}); 42 + 43 + $AtprotoSessionCopyWith<$Res> get session; 44 + } 45 + 46 + /// @nodoc 47 + class _$SessionCopyWithImpl<$Res, $Val extends Session> 48 + implements $SessionCopyWith<$Res> { 49 + _$SessionCopyWithImpl(this._value, this._then); 50 + 51 + // ignore: unused_field 52 + final $Val _value; 53 + // ignore: unused_field 54 + final $Res Function($Val) _then; 55 + 56 + /// Create a copy of Session 57 + /// with the given fields replaced by the non-null parameter values. 58 + @pragma('vm:prefer-inline') 59 + @override 60 + $Res call({Object? session = null, Object? token = null}) { 61 + return _then( 62 + _value.copyWith( 63 + session: null == session 64 + ? _value.session 65 + : session // ignore: cast_nullable_to_non_nullable 66 + as AtprotoSession, 67 + token: null == token 68 + ? _value.token 69 + : token // ignore: cast_nullable_to_non_nullable 70 + as String, 71 + ) 72 + as $Val, 73 + ); 74 + } 75 + 76 + /// Create a copy of Session 77 + /// with the given fields replaced by the non-null parameter values. 78 + @override 79 + @pragma('vm:prefer-inline') 80 + $AtprotoSessionCopyWith<$Res> get session { 81 + return $AtprotoSessionCopyWith<$Res>(_value.session, (value) { 82 + return _then(_value.copyWith(session: value) as $Val); 83 + }); 84 + } 85 + } 86 + 87 + /// @nodoc 88 + abstract class _$$SessionImplCopyWith<$Res> implements $SessionCopyWith<$Res> { 89 + factory _$$SessionImplCopyWith( 90 + _$SessionImpl value, 91 + $Res Function(_$SessionImpl) then, 92 + ) = __$$SessionImplCopyWithImpl<$Res>; 93 + @override 94 + @useResult 95 + $Res call({AtprotoSession session, String token}); 96 + 97 + @override 98 + $AtprotoSessionCopyWith<$Res> get session; 99 + } 100 + 101 + /// @nodoc 102 + class __$$SessionImplCopyWithImpl<$Res> 103 + extends _$SessionCopyWithImpl<$Res, _$SessionImpl> 104 + implements _$$SessionImplCopyWith<$Res> { 105 + __$$SessionImplCopyWithImpl( 106 + _$SessionImpl _value, 107 + $Res Function(_$SessionImpl) _then, 108 + ) : super(_value, _then); 109 + 110 + /// Create a copy of Session 111 + /// with the given fields replaced by the non-null parameter values. 112 + @pragma('vm:prefer-inline') 113 + @override 114 + $Res call({Object? session = null, Object? token = null}) { 115 + return _then( 116 + _$SessionImpl( 117 + session: null == session 118 + ? _value.session 119 + : session // ignore: cast_nullable_to_non_nullable 120 + as AtprotoSession, 121 + token: null == token 122 + ? _value.token 123 + : token // ignore: cast_nullable_to_non_nullable 124 + as String, 125 + ), 126 + ); 127 + } 128 + } 129 + 130 + /// @nodoc 131 + @JsonSerializable() 132 + class _$SessionImpl implements _Session { 133 + const _$SessionImpl({required this.session, required this.token}); 134 + 135 + factory _$SessionImpl.fromJson(Map<String, dynamic> json) => 136 + _$$SessionImplFromJson(json); 137 + 138 + @override 139 + final AtprotoSession session; 140 + @override 141 + final String token; 142 + 143 + @override 144 + String toString() { 145 + return 'Session(session: $session, token: $token)'; 146 + } 147 + 148 + @override 149 + bool operator ==(Object other) { 150 + return identical(this, other) || 151 + (other.runtimeType == runtimeType && 152 + other is _$SessionImpl && 153 + (identical(other.session, session) || other.session == session) && 154 + (identical(other.token, token) || other.token == token)); 155 + } 156 + 157 + @JsonKey(includeFromJson: false, includeToJson: false) 158 + @override 159 + int get hashCode => Object.hash(runtimeType, session, token); 160 + 161 + /// Create a copy of Session 162 + /// with the given fields replaced by the non-null parameter values. 163 + @JsonKey(includeFromJson: false, includeToJson: false) 164 + @override 165 + @pragma('vm:prefer-inline') 166 + _$$SessionImplCopyWith<_$SessionImpl> get copyWith => 167 + __$$SessionImplCopyWithImpl<_$SessionImpl>(this, _$identity); 168 + 169 + @override 170 + Map<String, dynamic> toJson() { 171 + return _$$SessionImplToJson(this); 172 + } 173 + } 174 + 175 + abstract class _Session implements Session { 176 + const factory _Session({ 177 + required final AtprotoSession session, 178 + required final String token, 179 + }) = _$SessionImpl; 180 + 181 + factory _Session.fromJson(Map<String, dynamic> json) = _$SessionImpl.fromJson; 182 + 183 + @override 184 + AtprotoSession get session; 185 + @override 186 + String get token; 187 + 188 + /// Create a copy of Session 189 + /// with the given fields replaced by the non-null parameter values. 190 + @override 191 + @JsonKey(includeFromJson: false, includeToJson: false) 192 + _$$SessionImplCopyWith<_$SessionImpl> get copyWith => 193 + throw _privateConstructorUsedError; 194 + }
+16
lib/models/session.g.dart
··· 1 + // GENERATED CODE - DO NOT MODIFY BY HAND 2 + 3 + part of 'session.dart'; 4 + 5 + // ************************************************************************** 6 + // JsonSerializableGenerator 7 + // ************************************************************************** 8 + 9 + _$SessionImpl _$$SessionImplFromJson(Map<String, dynamic> json) => 10 + _$SessionImpl( 11 + session: AtprotoSession.fromJson(json['session'] as Map<String, dynamic>), 12 + token: json['token'] as String, 13 + ); 14 + 15 + Map<String, dynamic> _$$SessionImplToJson(_$SessionImpl instance) => 16 + <String, dynamic>{'session': instance.session, 'token': instance.token};
+7 -2
lib/providers/notifications_provider.dart
··· 3 3 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:grain/api.dart'; 6 + import 'package:grain/auth.dart'; 6 7 import 'package:grain/main.dart'; 7 8 import 'package:grain/models/notification.dart'; 8 9 import 'package:grain/websocket_service.dart'; ··· 20 21 } 21 22 22 23 void _connectAndListen() async { 23 - // Get the current access token and wsUrl from apiService 24 - final accessToken = apiService.hasToken ? apiService.getAccessToken() : null; 24 + final session = await auth.getValidSession(); 25 + final accessToken = session?.token; 25 26 final wsUrl = AppConfig.wsUrl; 27 + if (accessToken == null) { 28 + state = AsyncValue.error('No access token', StackTrace.current); 29 + return; 30 + } 26 31 _wsService = WebSocketService( 27 32 wsUrl: wsUrl, 28 33 accessToken: accessToken,