diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 6c0be27b..a1ebf92d 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2115,6 +2115,9 @@ class MainActivity: FlutterFragmentActivity() { Gobackend.setExtensionAuthCodeByID(extId, code) Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin") } + if (isSessionGrant) { + requireSuccessfulExtensionAction(extId, "completeGrant", json) + } android.util.Log.i("SpotiFLAC", "Extension callback complete for $extId: $json") if (isSessionGrant) { withContext(Dispatchers.Main) { @@ -2132,6 +2135,23 @@ class MainActivity: FlutterFragmentActivity() { } } + private fun requireSuccessfulExtensionAction(extensionId: String, actionName: String, response: String) { + val obj = try { + JSONObject(response) + } catch (e: Exception) { + throw IllegalStateException( + "Extension $actionName for $extensionId returned invalid JSON: ${response.take(240)}" + ) + } + if (obj.optBoolean("success", false)) { + return + } + val error = obj.optString("error") + .ifBlank { obj.optString("message") } + .ifBlank { response.take(240) } + throw IllegalStateException("Extension $actionName failed for $extensionId: $error") + } + private fun notifySessionGrantCompleted(extensionId: String, success: Boolean) { val payload = mapOf( "extension_id" to extensionId, @@ -3340,6 +3360,16 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } + "completeExtensionSessionGrant" -> { + val extensionId = call.argument("extension_id") ?: "" + val grant = call.argument("grant") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setExtensionSessionGrantByID(extensionId, grant) + val json = Gobackend.invokeExtensionActionJSON(extensionId, "completeGrant") + requireSuccessfulExtensionAction(extensionId, "completeGrant", json) + } + result.success(true) + } "setExtensionTokens" -> { val extensionId = call.argument("extension_id") ?: "" val accessToken = call.argument("access_token") ?: "" diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 9d1debdf..87f47eb5 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -124,12 +124,24 @@ import Gobackend } streamQueue.async { var err: NSError? + var response: String? if isSessionGrant { GobackendSetExtensionSessionGrantByID(state, code) - _ = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err) + response = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err) } else { GobackendSetExtensionAuthCodeByID(state, code) - _ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err) + response = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err) + } + if err == nil && isSessionGrant { + do { + try self.requireSuccessfulExtensionAction( + extensionId: state, + actionName: "completeGrant", + response: response + ) + } catch { + err = error as NSError + } } if let err = err { NSLog( @@ -143,6 +155,39 @@ import Gobackend return true } + private func requireSuccessfulExtensionAction( + extensionId: String, + actionName: String, + response: String? + ) throws { + let text = response ?? "" + guard let data = text.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw NSError( + domain: "SpotiFLAC", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "Extension \(actionName) for \(extensionId) returned invalid JSON: \(String(text.prefix(240)))" + ] + ) + } + if (obj["success"] as? Bool) == true { + return + } + let error = + (obj["error"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? + (obj["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? + String(text.prefix(240)) + throw NSError( + domain: "SpotiFLAC", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: "Extension \(actionName) failed for \(extensionId): \(error)" + ] + ) + } + private func notifySessionGrantCompleted(extensionId: String) { let payload: [String: Any] = [ "extension_id": extensionId, @@ -833,6 +878,20 @@ import Gobackend let authCode = args["auth_code"] as! String GobackendSetExtensionAuthCodeByID(extensionId, authCode) return nil + + case "completeExtensionSessionGrant": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let grant = args["grant"] as! String + GobackendSetExtensionSessionGrantByID(extensionId, grant) + let response = GobackendInvokeExtensionActionJSON(extensionId, "completeGrant", &error) + if let error = error { throw error } + try requireSuccessfulExtensionAction( + extensionId: extensionId, + actionName: "completeGrant", + response: response + ) + return true case "setExtensionTokens": let args = call.arguments as! [String: Any] diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 8970b67b..783337f4 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -547,9 +547,7 @@ class PlatformBridge { } static Future setAllowPrivateNetwork(bool allowed) async { - await _channel.invokeMethod('setAllowPrivateNetwork', { - 'allowed': allowed, - }); + await _channel.invokeMethod('setAllowPrivateNetwork', {'allowed': allowed}); } static Future> checkDuplicate( @@ -1468,6 +1466,22 @@ class PlatformBridge { }); } + static Future completeExtensionSessionGrant( + String extensionId, + String grant, + ) async { + _log.d('completeExtensionSessionGrant: $extensionId'); + final result = await _channel.invokeMethod( + 'completeExtensionSessionGrant', + {'extension_id': extensionId, 'grant': grant}, + ); + final success = result != false; + _extensionSessionGrantEvents.add( + ExtensionSessionGrantEvent(extensionId: extensionId, success: success), + ); + return success; + } + static Future setExtensionTokens( String extensionId, { required String accessToken, diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index 9f24f3d3..54085f0e 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -115,68 +115,190 @@ Future showExtensionVerificationHelpDialog( final message = immediateFailure ? l10n.extensionVerificationHelpMessageManual : l10n.extensionVerificationHelpMessageWaiting; + final normalizedExtensionId = extensionId.trim(); + BuildContext? activeDialogContext; + late final StreamSubscription grantSub; + grantSub = PlatformBridge.extensionSessionGrantEvents() + .where( + (event) => + event.success && event.extensionId.trim() == normalizedExtensionId, + ) + .listen((_) { + final dialogContext = activeDialogContext; + if (dialogContext == null || !dialogContext.mounted) return; + _log.i( + 'Closing verification help dialog after $normalizedExtensionId grant', + ); + Navigator.of(dialogContext, rootNavigator: true).pop(); + }); - await showDialog( - context: context, - useRootNavigator: true, - barrierDismissible: false, - builder: (dialogContext) { - final dialogL10n = dialogContext.l10n; - return AlertDialog( - title: Text(title), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(message), - const SizedBox(height: 16), - DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(dialogContext).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: SelectableText( - authUri.toString(), - maxLines: 4, - minLines: 1, + try { + await showDialog( + context: context, + useRootNavigator: true, + barrierDismissible: false, + builder: (dialogContext) { + activeDialogContext = dialogContext; + final dialogL10n = dialogContext.l10n; + return AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(message), + const SizedBox(height: 16), + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of( + dialogContext, + ).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + authUri.toString(), + maxLines: 4, + minLines: 1, + ), ), ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(dialogL10n.extensionVerificationClose), + ), + TextButton.icon( + icon: const Icon(Icons.copy), + label: Text(dialogL10n.extensionVerificationCopyLink), + onPressed: () { + Clipboard.setData(ClipboardData(text: authUri.toString())); + ScaffoldMessenger.maybeOf(dialogContext)?.showSnackBar( + SnackBar( + content: Text(dialogL10n.extensionVerificationLinkCopied), + ), + ); + }, + ), + TextButton.icon( + icon: const Icon(Icons.content_paste), + label: const Text('Paste callback'), + onPressed: () { + unawaited( + _completeSessionGrantFromClipboard( + dialogContext, + extensionId, + ), + ); + }, + ), + FilledButton.icon( + icon: const Icon(Icons.open_in_browser), + label: Text(dialogL10n.extensionVerificationOpenBrowser), + onPressed: () { + unawaited(_launchVerificationUrl(authUri, browserMode)); + }, ), ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(dialogL10n.extensionVerificationClose), - ), - TextButton.icon( - icon: const Icon(Icons.copy), - label: Text(dialogL10n.extensionVerificationCopyLink), - onPressed: () { - Clipboard.setData(ClipboardData(text: authUri.toString())); - ScaffoldMessenger.maybeOf(dialogContext)?.showSnackBar( - SnackBar( - content: Text(dialogL10n.extensionVerificationLinkCopied), - ), - ); - }, - ), - FilledButton.icon( - icon: const Icon(Icons.open_in_browser), - label: Text(dialogL10n.extensionVerificationOpenBrowser), - onPressed: () { - unawaited(_launchVerificationUrl(authUri, browserMode)); - }, - ), - ], - ); - }, - ); + ); + }, + ); + } finally { + activeDialogContext = null; + await grantSub.cancel(); + } return true; } +Future _completeSessionGrantFromClipboard( + BuildContext context, + String extensionId, +) async { + final messenger = ScaffoldMessenger.maybeOf(context); + try { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text?.trim() ?? ''; + final parsed = _parseSessionGrantCallback( + text, + fallbackExtensionId: extensionId, + ); + if (parsed == null) { + messenger?.showSnackBar( + const SnackBar(content: Text('No verification callback found')), + ); + return; + } + + final success = await PlatformBridge.completeExtensionSessionGrant( + parsed.extensionId, + parsed.grant, + ); + if (!context.mounted) return; + messenger?.showSnackBar( + SnackBar( + content: Text( + success ? 'Verification completed' : 'Verification failed', + ), + ), + ); + if (success) { + Navigator.of(context, rootNavigator: true).pop(); + } + } catch (e) { + if (!context.mounted) return; + messenger?.showSnackBar( + SnackBar(content: Text('Verification callback failed: $e')), + ); + } +} + +({String extensionId, String grant})? _parseSessionGrantCallback( + String text, { + required String fallbackExtensionId, +}) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return null; + + String? grant; + String? state; + final uri = Uri.tryParse(trimmed); + if (uri != null) { + grant = uri.queryParameters['grant'] ?? uri.queryParameters['code']; + state = uri.queryParameters['state']; + + final nestedCallback = uri.queryParameters['cb']; + if ((grant == null || grant.trim().isEmpty) && + nestedCallback != null && + nestedCallback.trim().isNotEmpty) { + final nested = _parseSessionGrantCallback( + nestedCallback, + fallbackExtensionId: fallbackExtensionId, + ); + if (nested != null) return nested; + } + } + + grant ??= _firstRegexGroup(trimmed, RegExp(r'(?:^|[?&#\s])grant=([^&#\s]+)')); + grant ??= _firstRegexGroup(trimmed, RegExp(r'(?:^|[?&#\s])code=([^&#\s]+)')); + state ??= _firstRegexGroup(trimmed, RegExp(r'(?:^|[?&#\s])state=([^&#\s]+)')); + + grant = grant == null ? null : Uri.decodeComponent(grant.trim()); + state = state == null ? null : Uri.decodeComponent(state.trim()); + final extension = (state != null && state.isNotEmpty) + ? state + : fallbackExtensionId.trim(); + if (extension.isEmpty || grant == null || grant.isEmpty) return null; + return (extensionId: extension, grant: grant); +} + +String? _firstRegexGroup(String input, RegExp regex) { + final match = regex.firstMatch(input); + return match?.group(1); +} + Future _launchVerificationUrl(Uri uri, String browserMode) async { final preferInApp = browserMode.trim().toLowerCase() == 'in_app_first'; final firstMode = preferInApp