diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index dd9907e..c7f1495 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 7A86921416E7E45FF352EBA8 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 8F166BC65D95B65548923E6F /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 928D6E262D25380700E851C5 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -142,6 +143,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 928D6E262D25380700E851C5 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -511,6 +513,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -519,6 +522,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Chrono Sheet"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -706,6 +710,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -714,6 +719,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Chrono Sheet"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -739,6 +745,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; @@ -747,6 +754,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Chrono Sheet"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/lib/google/google_helper.dart b/lib/google/google_helper.dart index e3b815c..0b07405 100644 --- a/lib/google/google_helper.dart +++ b/lib/google/google_helper.dart @@ -1,16 +1,22 @@ +import 'dart:convert'; + import 'package:chrono_sheet/generated/app_localizations.dart'; import 'package:chrono_sheet/logging/logging.dart'; import 'package:chrono_sheet/main.dart'; import 'package:flutter/material.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:googleapis/sheets/v4.dart' as sheets; -import 'package:http/src/client.dart' as http; +import 'package:googleapis/drive/v3.dart' as drive; +import 'package:http/http.dart' as http; import '../http/AuthenticatedHttpClient.dart'; import '../util/rich_text_util.dart'; final _logger = getNamedLogger(); -final signIn = GoogleSignIn(scopes: [sheets.SheetsApi.spreadsheetsScope, sheets.SheetsApi.driveFileScope]); +final signIn = GoogleSignIn(scopes: [ + sheets.SheetsApi.spreadsheetsScope, + drive.DriveApi.driveScope, +]); http.Client? _clientOverride; void setClientOverride(http.Client client) { @@ -24,30 +30,97 @@ Future getAuthenticatedGoogleApiHttpClient() async { } _logger.fine("trying to sign in silently"); var googleAccount = await signIn.signInSilently(); - if (googleAccount == null) { - _logger.fine("failed to sign in silently, signing in normally"); - await showDialog( - context: navigatorKey.currentContext!, - builder: (context) { - final theme = Theme.of(context); - return AlertDialog( - content: buildRichText(AppLocalizations.of(context).textPermissionsRationale, theme.textTheme), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(AppLocalizations.of(context).textOk), - ), - ], - ); - }, - ); - googleAccount = await signIn.signIn(); - } else { - _logger.fine("successfully signed in silently"); + while (true) { + if (googleAccount == null) { + _logger.info("failed to sign in silently, signing in normally"); + await _showPermissionsRationale(); + googleAccount = await signIn.signIn(); + } else { + _logger.info("successfully signed in silently"); + } + if (googleAccount == null) { + _logger.info("failed to explicitly sign in into google"); + continue; + } + + Set missingScopes = await _getMissingScopes(googleAccount); + if (missingScopes.isNotEmpty) { + _logger.info("detected that the user didn't provide the following scopes during google login: $missingScopes"); + await _showPermissionsRationale(missingScopes); + final granted = await signIn.requestScopes(missingScopes.toList()); + if (!granted) { + _logger.info("didn't get required scopes during google login even after explicit request"); + continue; + } + googleAccount = await signIn.signInSilently(); + } else { + final headers = await googleAccount.authHeaders; + return AuthenticatedHttpClient(headers); + } } - if (googleAccount == null) { - throw StateError("can not login into google"); +} + +Future _showPermissionsRationale([Set missingScopes = const {}]) { + return showDialog( + context: navigatorKey.currentContext!, + builder: (context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + String title; + if (missingScopes.isEmpty) { + title = l10n.titlePermissionsRationale; + } else { + title = l10n.errorPermissionsNotGranted; + for (final scope in missingScopes) { + title += "\n * **"; + switch (scope) { + case drive.DriveApi.driveScope: + title += l10n.permissionDrive; + break; + case sheets.SheetsApi.spreadsheetsScope: + title += l10n.permissionSheets; + break; + default: + title += scope; + } + title += "**"; + } + } + return AlertDialog( + title: Row(children: [ + Icon(Icons.warning, color: Colors.red, size: theme.textTheme.headlineLarge?.fontSize ?? 32.0), + SizedBox(width: 16), + Expanded(child: buildRichText(title, theme.textTheme)) + ]), + content: buildRichText(AppLocalizations.of(context).textPermissionsRationale, theme.textTheme), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(AppLocalizations.of(context).textOk), + ), + ], + ); + }, + ); +} + +Future> _getMissingScopes(GoogleSignInAccount account) async { + final auth = await account.authentication; + final token = auth.accessToken; + final response = await http.get( + Uri.parse('https://oauth2.googleapis.com/tokeninfo?access_token=$token'), + ); + final result = Set.of(signIn.scopes); + if (response.statusCode == 200) { + final tokenInfo = json.decode(response.body); + final scopes = tokenInfo["scope"]?.toString(); + if (scopes != null) { + for (final scope in signIn.scopes) { + if (scopes.contains(scope)) { + result.remove(scope); + } + } + } } - final headers = await googleAccount.authHeaders; - return AuthenticatedHttpClient(headers); + return result; } diff --git a/lib/google/state/google_login_state.dart b/lib/google/state/google_login_state.dart index 1c2e1ac..c8165b7 100644 --- a/lib/google/state/google_login_state.dart +++ b/lib/google/state/google_login_state.dart @@ -31,7 +31,7 @@ class LoginState extends _$LoginState { Future logout() async { _logger.fine("got a request to logout"); state = AsyncValue.loading(); - await signIn.signOut(); + await signIn.disconnect(); state = AsyncValue.data(false); } } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 679e55f..f154266 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4,6 +4,7 @@ "titleSelectFile": "Select Google Sheet File", "titleAddNewCategory": "Add New Category", "titleAddNewFile": "Add New File", + "titlePermissionsRationale": "**Your Permissions Enable Key Features**", "textReset": "Reset", "textSave": "Save", @@ -11,7 +12,7 @@ "textAdd": "Add", "textOk": "Ok", "textClose": "Close", - "textPermissionsRationale": "**Why Does This App Need Permissions?**\n\nOur app requires access to your Google Drive and Google Sheets to function properly:\n\n 1. **Google Drive:** To show you a list of your existing Google Sheets and let you create a new one if needed.\n\n 2. **Google Sheets:** To store your stopwatch measurements directly in your chosen Google Sheet.\n\nWe only access the files you select or create through the app. Your data remains private and secure.", + "textPermissionsRationale": "Our app requires access to your Google Drive and Google Sheets to function properly:\n\n 1. **Google Drive:** To show you a list of your existing Google Sheets and let you create a new one if needed.\n\n 2. **Google Sheets:** To store your stopwatch measurements directly in your chosen Google Sheet.\n\nWe only access the files you select or create through the app. Your data remains private and secure.", "hintCreateCategory": "create a category", "hintSelectFile": "tap to select a file", @@ -22,6 +23,10 @@ "errorCanNotParseCategories": "can not parse categories", "errorNoFileIsSelected": "no file is selected", "errorNoCategoryIsSelected": "no category is selected", + "errorPermissionsNotGranted": "The following mandatory permissions are not granted:\n", + + "permissionDrive": "Google Drive", + "permissionSheets": "Google Sheets", "progressFileCreationInProgress": "file creation is in progress...", "progressParsingCategories": "parsing categories from file..." diff --git a/lib/util/rich_text_util.dart b/lib/util/rich_text_util.dart index 9f06615..a3bc0ab 100644 --- a/lib/util/rich_text_util.dart +++ b/lib/util/rich_text_util.dart @@ -10,7 +10,7 @@ RichText buildRichText(String text, TextTheme theme) { while (offset < text.length) { int markerStart = text.indexOf(_Marker.bold, offset); if (markerStart >= 0) { - int markerEnd = text.indexOf(_Marker.bold, markerStart + 1); + int markerEnd = text.indexOf(_Marker.bold, markerStart + _Marker.bold.length); if (markerEnd > markerStart) { if (offset < markerStart) { spans.add(TextSpan( diff --git a/pubspec.lock b/pubspec.lock index 4b4ad62..580e967 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -523,7 +523,7 @@ packages: source: hosted version: "4.2.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 diff --git a/pubspec.yaml b/pubspec.yaml index 316dfbe..fd341b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: firebase_analytics: 11.3.6 shared_preferences: 2.3.4 uuid: 4.5.1 + http: 1.2.2 dev_dependencies: flutter_test: