mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-26 14:01:03 +02:00
feat(extensions): complete signed-session grants from help dialog
Add a platform bridge to finish session grants on Android and iOS with JSON success validation, let users paste callback URLs from the clipboard, and auto-dismiss the verification help dialog after grant.
This commit is contained in:
@@ -547,9 +547,7 @@ class PlatformBridge {
|
||||
}
|
||||
|
||||
static Future<void> setAllowPrivateNetwork(bool allowed) async {
|
||||
await _channel.invokeMethod('setAllowPrivateNetwork', {
|
||||
'allowed': allowed,
|
||||
});
|
||||
await _channel.invokeMethod('setAllowPrivateNetwork', {'allowed': allowed});
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> checkDuplicate(
|
||||
@@ -1468,6 +1466,22 @@ class PlatformBridge {
|
||||
});
|
||||
}
|
||||
|
||||
static Future<bool> 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<void> setExtensionTokens(
|
||||
String extensionId, {
|
||||
required String accessToken,
|
||||
|
||||
@@ -115,68 +115,190 @@ Future<bool> showExtensionVerificationHelpDialog(
|
||||
final message = immediateFailure
|
||||
? l10n.extensionVerificationHelpMessageManual
|
||||
: l10n.extensionVerificationHelpMessageWaiting;
|
||||
final normalizedExtensionId = extensionId.trim();
|
||||
BuildContext? activeDialogContext;
|
||||
late final StreamSubscription<ExtensionSessionGrantEvent> 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<void>(
|
||||
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<void>(
|
||||
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<void> _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<bool> _launchVerificationUrl(Uri uri, String browserMode) async {
|
||||
final preferInApp = browserMode.trim().toLowerCase() == 'in_app_first';
|
||||
final firstMode = preferInApp
|
||||
|
||||
Reference in New Issue
Block a user