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:
zarzet
2026-07-05 01:50:42 +07:00
parent edb241faf7
commit 6b30eba947
4 changed files with 284 additions and 59 deletions
@@ -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<String>("extension_id") ?: ""
val grant = call.argument<String>("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<String>("extension_id") ?: ""
val accessToken = call.argument<String>("access_token") ?: ""
+61 -2
View File
@@ -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]
+17 -3
View File
@@ -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,
+176 -54
View File
@@ -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