From 3fb393166fbe4df4b9f7ce95bd08c4650cb9a8ac Mon Sep 17 00:00:00 2001 From: copilot Date: Wed, 7 Oct 2026 07:32:16 +0200 Subject: [PATCH] Fix tag directory sorting and follow Client-Integrity. Paginate tag (and ASC) lists via GQL with integrity retry; mint follow integrity from a WebView because Dio /integrity tokens are bad_bot. Co-authored-by: Cursor --- DESIGN.md | 7 +- lib/apis/twitch_api.dart | 4 +- lib/apis/twitch_gql_api.dart | 139 +++++++---- lib/apis/twitch_integrity.dart | 81 ++++++ .../home/stream_list/stream_list_store.dart | 224 +++++++++-------- lib/screens/settings/stores/user_store.dart | 106 ++++++-- lib/widgets/integrity_webview_dialog.dart | 232 ++++++++++++++++++ lib/widgets/user_actions_modal.dart | 121 ++++++--- test/apis/twitch_gql_follow_test.dart | 30 +++ test/apis/twitch_gql_streams_test.dart | 129 ++++++++++ test/apis/twitch_integrity_test.dart | 42 ++++ .../stream_list/stream_list_store_test.dart | 203 +++++++++++++++ .../settings/stores/user_store_test.dart | 86 +++++++ 13 files changed, 1197 insertions(+), 207 deletions(-) create mode 100644 lib/apis/twitch_integrity.dart create mode 100644 lib/widgets/integrity_webview_dialog.dart create mode 100644 test/apis/twitch_gql_streams_test.dart create mode 100644 test/apis/twitch_integrity_test.dart create mode 100644 test/screens/home/stream_list/stream_list_store_test.dart diff --git a/DESIGN.md b/DESIGN.md index 3ad3817c..07a0fe02 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -51,9 +51,10 @@ Settings → General → Stream languages (multi-select chips). Default `de`. Ap - Helix `language=` when one language; multi-language / tags via Helix over-fetch + client filter - Viewer sort is persisted (`streamViewerSort`) -- **Most viewers (desc):** Helix directory order (paginates; tags via client `tags[]` match) -- **Fewest viewers (asc):** GQL `VIEWER_COUNT_ASC` for the first ~30 (true fewest), then Helix fill + dedupe for infinite scroll (GQL page-2 needs Client-Integrity and always fails) -- Tag directories never rely on GQL cursors for load-more +- **Tag lists (asc + desc):** GQL `streams` with `freeformTags` + `sort` + cursor. Page-2+ uses Client-Integrity (same mint/retry as follow via `UserStore.runWithGqlIntegrity`). Helix + client tag filter only if integrity fails / no web session. +- **Follow / integrity:** Dio `POST /integrity` is almost always `is_bad_bot`. Follow retries via `IntegrityWebViewDialog` (capture `Client-Integrity` + `X-Device-Id` from Twitch's own WebView GQL traffic), then `UserStore.applyWebIntegrity`. +- **Top / category most viewers (desc):** Helix directory order (paginates) +- **Top / category fewest (asc):** GQL `VIEWER_COUNT_ASC` with the same integrity pagination; Helix fill + client re-sort as fallback ## Build diff --git a/lib/apis/twitch_api.dart b/lib/apis/twitch_api.dart index ca9f49f8..f3cc787d 100644 --- a/lib/apis/twitch_api.dart +++ b/lib/apis/twitch_api.dart @@ -271,8 +271,8 @@ class TwitchApi extends BaseApiClient { /// optional [freeformTag] (case-insensitive tag name), or the directory ends. /// /// Helix is always viewer-desc and supports at most one `language` query - /// param; tags are filtered client-side. Prefer this over GQL for pagination - /// (GQL page-2 requires a non-bot Client-Integrity token). + /// param; tags are filtered client-side. Used as fallback when GQL directory + /// pagination lacks a Client-Integrity token. /// /// When a [freeformTag] filter yields no matches in a Helix window, this /// keeps scanning (up to [maxPages]) and still returns the Helix cursor so diff --git a/lib/apis/twitch_gql_api.dart b/lib/apis/twitch_gql_api.dart index 0fce183a..3923d500 100644 --- a/lib/apis/twitch_gql_api.dart +++ b/lib/apis/twitch_gql_api.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/models/playback_access_token.dart'; import 'package:frosty/models/stream.dart'; @@ -31,27 +32,49 @@ class TwitchGqlApi extends BaseApiClient { 'Content-Type': 'application/json', }; - Future _gql(Map body) async { + Future _gql( + Map body, { + Map? extraHeaders, + }) async { final response = await post( '/gql', data: body, - headers: _gqlHeaders, + headers: {..._gqlHeaders, ...?extraHeaders}, ); final errors = response['errors']; if (errors is List && errors.isNotEmpty) { + final message = (errors.first as JsonMap)['message'] as String? ?? + 'Twitch GQL error'; + if (message.toLowerCase().contains('integrity')) { + throw IntegrityCheckException(message); + } final data = response['data']; final dataEmpty = data == null || (data is Map && data.values.every((value) => value == null)); if (dataEmpty) { - final message = (errors.first as JsonMap)['message'] as String? ?? - 'Twitch GQL error'; throw ApiException(message); } } return response; } + /// Optional web-session headers for directory pagination (page-2+). + Map _directoryHeaders({ + String? authToken, + String? integrityToken, + String? deviceId, + }) { + return { + 'User-Agent': playbackUserAgent, + if (authToken != null && authToken.isNotEmpty) + 'Authorization': 'OAuth $authToken', + if (deviceId != null && deviceId.isNotEmpty) 'X-Device-Id': deviceId, + if (integrityToken != null && integrityToken.isNotEmpty) + 'Client-Integrity': integrityToken, + }; + } + /// Browser-consistent User-Agent for playback-related requests. Twitch has /// rejected access-token requests with non-browser UAs before /// (streamlink#6574); sending one consistent with the web client ID and @@ -213,15 +236,18 @@ class TwitchGqlApi extends BaseApiClient { /// Live streams matching optional [freeformTags] / [languages], sorted by viewers. /// - /// Twitch caps `first` at 30 for this connection. Cursor pagination often - /// fails with `IntegrityCheckFailed` without a web integrity token — callers - /// should treat a failed page-2 as end-of-list. + /// Twitch caps `first` at 30 for this connection. Cursor pagination needs a + /// Client-Integrity token (+ matching [deviceId] / web [authToken]); without + /// them page-2 throws [IntegrityCheckException]. Future getStreams({ List? freeformTags, List? languages, StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, + String? authToken, + String? integrityToken, + String? deviceId, }) async { final cappedFirst = first.clamp(1, 30); final gqlLangs = languages == null || languages.isEmpty @@ -231,24 +257,31 @@ class TwitchGqlApi extends BaseApiClient { .where((l) => l.isNotEmpty) .toList(); - final response = await _gql({ - 'operationName': 'FrostyTopStreams', - 'query': - r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' - r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' - r' edges { cursor node {' - r' id title viewersCount createdAt language' - r' game { id name }' - r' broadcaster { id login displayName }' - r' } } pageInfo { hasNextPage } } }', - 'variables': { - 'first': cappedFirst, - 'after': cursor, - 'tags': freeformTags, - 'sort': sort.gqlValue, - 'langs': gqlLangs, + final response = await _gql( + { + 'operationName': 'FrostyTopStreams', + 'query': + r'query FrostyTopStreams($first: Int, $after: Cursor, $tags: [String!], $sort: StreamSort, $langs: [Language!]) {' + r' streams(first: $first, after: $after, options: { sort: $sort, freeformTags: $tags, languages: $langs }) {' + r' edges { cursor node {' + r' id title viewersCount createdAt language' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } }', + 'variables': { + 'first': cappedFirst, + 'after': cursor, + 'tags': freeformTags, + 'sort': sort.gqlValue, + 'langs': gqlLangs, + }, }, - }); + extraHeaders: _directoryHeaders( + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ), + ); final connection = (response['data'] as JsonMap?)?['streams'] as JsonMap? ?? const {}; @@ -262,6 +295,9 @@ class TwitchGqlApi extends BaseApiClient { StreamViewerSort sort = StreamViewerSort.viewersDesc, String? cursor, int first = 30, + String? authToken, + String? integrityToken, + String? deviceId, }) async { final cappedFirst = first.clamp(1, 30); final gqlLangs = languages == null || languages.isEmpty @@ -271,24 +307,31 @@ class TwitchGqlApi extends BaseApiClient { .where((l) => l.isNotEmpty) .toList(); - final response = await _gql({ - 'operationName': 'FrostyGameStreams', - 'query': - r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' - r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' - r' edges { cursor node {' - r' id title viewersCount createdAt language' - r' game { id name }' - r' broadcaster { id login displayName }' - r' } } pageInfo { hasNextPage } } } }', - 'variables': { - 'id': gameId, - 'first': cappedFirst, - 'after': cursor, - 'sort': sort.gqlValue, - 'langs': gqlLangs, + final response = await _gql( + { + 'operationName': 'FrostyGameStreams', + 'query': + r'query FrostyGameStreams($id: ID!, $first: Int, $after: Cursor, $sort: StreamSort, $langs: [Language!]) {' + r' game(id: $id) { streams(first: $first, after: $after, options: { sort: $sort, languages: $langs }) {' + r' edges { cursor node {' + r' id title viewersCount createdAt language' + r' game { id name }' + r' broadcaster { id login displayName }' + r' } } pageInfo { hasNextPage } } } }', + 'variables': { + 'id': gameId, + 'first': cappedFirst, + 'after': cursor, + 'sort': sort.gqlValue, + 'langs': gqlLangs, + }, }, - }); + extraHeaders: _directoryHeaders( + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ), + ); final game = (response['data'] as JsonMap?)?['game'] as JsonMap?; if (game == null) { @@ -354,8 +397,9 @@ class TwitchGqlApi extends BaseApiClient { /// Mints a Client-Integrity token for authenticated GQL mutations. /// /// Bound to [deviceId] and [authToken]; send the same values on follow calls. - /// Twitch may still reject bot-flagged tokens — then the mutation fails with - /// [IntegrityCheckException]. + /// Tokens minted outside a real browser are usually flagged `is_bad_bot` — + /// those throw [IntegrityCheckException] so callers can fall back to a + /// WebView capture. Future<({String token, int expirationMs})> fetchIntegrityToken({ required String authToken, required String deviceId, @@ -377,9 +421,16 @@ class TwitchGqlApi extends BaseApiClient { if (token == null || token.isEmpty) { throw const ApiException('Twitch integrity token missing from response'); } + if (TwitchIntegrity.isBadBot(token)) { + throw const IntegrityCheckException( + 'Twitch integrity token flagged as bad bot', + ); + } final expirationMs = expiration is int ? expiration - : int.tryParse('$expiration') ?? 0; + : int.tryParse('$expiration') ?? + TwitchIntegrity.expirationMs(token) ?? + 0; return (token: token, expirationMs: expirationMs); } diff --git a/lib/apis/twitch_integrity.dart b/lib/apis/twitch_integrity.dart new file mode 100644 index 00000000..efef0346 --- /dev/null +++ b/lib/apis/twitch_integrity.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`). +class TwitchIntegrity { + const TwitchIntegrity._(); + + /// Decodes the JSON payload embedded in an integrity token, if present. + static Map? decodePayload(String token) { + final parts = token.split('.'); + if (parts.length < 2) return null; + + for (final part in parts.skip(1)) { + if (part.isEmpty) continue; + try { + final normalized = base64Url.normalize(part); + final decoded = utf8.decode(base64Url.decode(normalized)); + final dynamic json = jsonDecode(decoded); + if (json is Map && + (json.containsKey('is_bad_bot') || + json.containsKey('client_id') || + json.containsKey('device_id'))) { + return Map.from(json); + } + } catch (_) { + // Try the next segment — token layout varies by version. + } + } + return null; + } + + /// Dio/`POST /integrity` tokens from non-browser clients are usually flagged. + static bool isBadBot(String token) { + final payload = decodePayload(token); + if (payload == null) return false; + final flag = payload['is_bad_bot']; + if (flag is bool) return flag; + if (flag is String) return flag.toLowerCase() == 'true'; + return false; + } + + /// Expiration in epoch millis from the payload, if present. + static int? expirationMs(String token) { + final payload = decodePayload(token); + if (payload == null) return null; + + final exp = payload['exp']; + if (exp is int) { + // Already millis if large; seconds if typical JWT. + return exp > 100000000000 ? exp : exp * 1000; + } + if (exp is String) { + final asInt = int.tryParse(exp); + if (asInt != null) { + return asInt > 100000000000 ? asInt : asInt * 1000; + } + final dt = DateTime.tryParse(exp); + if (dt != null) return dt.millisecondsSinceEpoch; + } + return null; + } + + /// Device id the token was minted for (`X-Device-Id` must match). + static String? deviceId(String token) { + final payload = decodePayload(token); + final id = payload?['device_id']; + return id is String && id.isNotEmpty ? id : null; + } +} + +/// Credentials captured from a browser/WebView GQL request. +class GqlIntegrityCredentials { + final String integrityToken; + final String deviceId; + final int expirationMs; + + const GqlIntegrityCredentials({ + required this.integrityToken, + required this.deviceId, + required this.expirationMs, + }); +} diff --git a/lib/screens/home/stream_list/stream_list_store.dart b/lib/screens/home/stream_list/stream_list_store.dart index f8e97b69..f7165dd4 100644 --- a/lib/screens/home/stream_list/stream_list_store.dart +++ b/lib/screens/home/stream_list/stream_list_store.dart @@ -51,9 +51,13 @@ abstract class ListStoreBase with Store { /// The pagination cursor for the streams. String? _streamsCursor; - /// After a GQL ASC first page, keep filling via Helix (GQL page-2 integrity - /// fails). Helix starts from the top; duplicates are skipped. - var _ascHelixFill = false; + /// When true, pages come from Helix (GQL cursor/integrity unavailable). + /// ASC Helix fill starts from the top; duplicates are skipped and the list + /// is re-sorted client-side. + var _helixFill = false; + + /// Last page was served by GQL with server-side sort — append in API order. + var _lastPageWasGql = false; /// The pagination cursor for the offline followed channels. String? _offlineChannelsCursor; @@ -64,7 +68,7 @@ abstract class ListStoreBase with Store { /// Returns whether or not there are more streams and loading status for pagination. @computed bool get hasMore => - isLoading == false && (_streamsCursor != null || _ascHelixFill); + isLoading == false && (_streamsCursor != null || _helixFill); @computed bool get isLoading => @@ -255,6 +259,14 @@ abstract class ListStoreBase with Store { } } + /// Prefer GQL for tag directories (both sorts) and for ascending Top/category. + bool get _preferGqlDirectory { + if (twitchGqlApi == null || _helixFill) return false; + if (listType == ListType.tag) return true; + return listType != ListType.followed && + sortOrder == StreamViewerSort.viewersAsc; + } + /// Fetches the streams based on the type and current cursor. @action Future getStreams() async { @@ -265,84 +277,41 @@ abstract class ListStoreBase with Store { try { final languages = _languageFilter; final isAsc = sortOrder == StreamViewerSort.viewersAsc; - // GQL ASC is correct fewest, but page-2 always fails IntegrityCheckFailed. - // First empty list → GQL ASC once; further pages → Helix fill + dedupe. - final gqlAscFirstPage = isAsc && - twitchGqlApi != null && - !_ascHelixFill && - _allStreams.isEmpty && - listType != ListType.followed; + final useGql = _preferGqlDirectory; + _lastPageWasGql = false; - final StreamsTwitch newStreams; - switch (listType) { - case ListType.followed: - newStreams = await twitchApi.getFollowedStreams( - id: authStore.user.details!.id, - cursor: _streamsCursor, - ); - break; - case ListType.top: - if (gqlAscFirstPage) { - newStreams = await twitchGqlApi!.getStreams( - sort: StreamViewerSort.viewersAsc, - languages: languages.isEmpty ? null : languages, - ); - } else { - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - cursor: _streamsCursor, - minCount: isAsc ? 30 : 50, - maxPages: isAsc ? 20 : 12, - ); - } - break; - case ListType.category: - if (gqlAscFirstPage) { - newStreams = await twitchGqlApi!.getGameStreams( - gameId: categoryId!, - sort: StreamViewerSort.viewersAsc, - languages: languages.isEmpty ? null : languages, - ); - } else { - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - gameId: categoryId!, - cursor: _streamsCursor, - minCount: isAsc ? 30 : 50, - maxPages: isAsc ? 20 : 12, - ); - } - break; - case ListType.tag: - if (gqlAscFirstPage) { - newStreams = await twitchGqlApi!.getStreams( - freeformTags: [freeformTag!], - sort: StreamViewerSort.viewersAsc, - languages: languages.isEmpty ? null : languages, - ); - } else { - // Desc, and ASC after GQL: Helix + tag filter (paginates). - newStreams = await twitchApi.getDirectoryStreams( - languages: languages, - freeformTag: freeformTag, - cursor: _streamsCursor, - minCount: 40, - maxPages: 25, - ); - } - break; + StreamsTwitch? newStreams; + if (listType == ListType.followed) { + newStreams = await twitchApi.getFollowedStreams( + id: authStore.user.details!.id, + cursor: _streamsCursor, + ); + } else if (useGql) { + newStreams = await _fetchGqlDirectory(languages: languages); + if (newStreams != null) { + _lastPageWasGql = true; + } } + // Helix: Top/category DESC, or GQL unavailable / integrity fallback. + newStreams ??= await twitchApi.getDirectoryStreams( + languages: languages, + gameId: listType == ListType.category ? categoryId : null, + freeformTag: listType == ListType.tag ? freeformTag : null, + cursor: _streamsCursor, + minCount: listType == ListType.tag ? 40 : (isAsc ? 30 : 50), + maxPages: listType == ListType.tag ? 25 : (isAsc ? 20 : 12), + ); + final next = newStreams.pagination['cursor']; - final hasHelixCursor = next != null && next.isNotEmpty; + final hasCursor = next != null && next.isNotEmpty; // Empty Helix window with cursor left ⇒ keep scanning (sparse tags). - final isHelixContinuation = - _streamsCursor != null || (_ascHelixFill && _allStreams.isNotEmpty); - if (isHelixContinuation && - newStreams.data.isEmpty && - !gqlAscFirstPage) { - if (hasHelixCursor) { + final isHelixContinuation = !_lastPageWasGql && + (_streamsCursor != null || + (_helixFill && _allStreams.isNotEmpty)); + if (isHelixContinuation && newStreams.data.isEmpty) { + if (hasCursor) { _streamsCursor = next; _error = null; _isAllStreamsLoading = false; @@ -350,20 +319,20 @@ abstract class ListStoreBase with Store { return; } _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; _error = null; return; } - // Keep API order for desc. ASC: sanitize page, then full-list sort after - // Helix fill so newly found streams land in fewest-first order. - final pageData = isAsc + // GQL pages are already server-sorted — keep API order. + // Helix ASC fill: sort page + full list so late matches land correctly. + final pageData = (!_lastPageWasGql && isAsc) ? (List.of(newStreams.data)..sort( (a, b) => a.viewerCount.compareTo(b.viewerCount), )) : newStreams.data; - if (_allStreams.isEmpty && !_ascHelixFill) { + if (_allStreams.isEmpty && !_helixFill) { _allStreams = pageData.asObservable(); } else { final existingIds = _allStreams.map((s) => s.userId).toSet(); @@ -372,23 +341,17 @@ abstract class ListStoreBase with Store { ); } - if (isAsc && _allStreams.length > 1) { + if (!_lastPageWasGql && isAsc && _allStreams.length > 1) { final sorted = _allStreams.toList() ..sort((a, b) => a.viewerCount.compareTo(b.viewerCount)); _allStreams = sorted.asObservable(); } - if (gqlAscFirstPage) { - // Ignore GQL cursor — page-2 integrity fails. Fill via Helix next. - _ascHelixFill = true; - _streamsCursor = null; - } else { - _streamsCursor = hasHelixCursor ? next : null; - if (_streamsCursor == null) { - _ascHelixFill = false; - } else if (isAsc) { - _ascHelixFill = true; - } + _streamsCursor = hasCursor ? next : null; + if (_streamsCursor == null) { + _helixFill = false; + } else if (!_lastPageWasGql && isAsc) { + _helixFill = true; } _error = null; @@ -397,15 +360,16 @@ abstract class ListStoreBase with Store { debugPrint('Streams SocketException: No internet connection'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; } } on ApiException catch (e) { _error = e.message; debugPrint('Streams ApiException: $e'); if (_allStreams.isNotEmpty) { - // GQL page-2 integrity or mid-scroll error: fall back to Helix fill. - if (isAscDirectory && !_ascHelixFill) { - _ascHelixFill = true; + // Mid-scroll GQL failure: fall back to Helix fill when useful. + if (!_helixFill && + (listType == ListType.tag || isAscDirectory)) { + _helixFill = true; _streamsCursor = null; _error = null; _isAllStreamsLoading = false; @@ -413,7 +377,7 @@ abstract class ListStoreBase with Store { return; } _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; _error = null; } } catch (e) { @@ -421,7 +385,7 @@ abstract class ListStoreBase with Store { debugPrint('Streams error: $e'); if (_streamsCursor != null && _allStreams.isNotEmpty) { _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; _error = null; } } finally { @@ -429,6 +393,63 @@ abstract class ListStoreBase with Store { } } + /// GQL directory page with integrity retry. Returns null to trigger Helix. + Future _fetchGqlDirectory({ + required List languages, + }) async { + final gql = twitchGqlApi; + if (gql == null) return null; + + final langs = languages.isEmpty ? null : languages; + + try { + return await authStore.user.runWithGqlIntegrity( + gqlToken: authStore.gqlToken, + action: ({authToken, integrityToken, deviceId}) { + switch (listType) { + case ListType.tag: + return gql.getStreams( + freeformTags: [freeformTag!], + sort: sortOrder, + languages: langs, + cursor: _streamsCursor, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + case ListType.category: + return gql.getGameStreams( + gameId: categoryId!, + sort: sortOrder, + languages: langs, + cursor: _streamsCursor, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + case ListType.top: + return gql.getStreams( + sort: sortOrder, + languages: langs, + cursor: _streamsCursor, + authToken: authToken, + integrityToken: integrityToken, + deviceId: deviceId, + ); + case ListType.followed: + throw StateError('GQL directory not used for followed'); + } + }, + ); + } on IntegrityCheckException catch (e) { + debugPrint('Streams GQL integrity: $e — falling back to Helix'); + _helixFill = true; + // Keep cursor null so Helix starts from the top (dedupe handles overlap). + _streamsCursor = null; + return null; + } + } + bool get isAscDirectory => sortOrder == StreamViewerSort.viewersAsc && listType != ListType.followed; @@ -514,7 +535,8 @@ abstract class ListStoreBase with Store { } _streamsCursor = null; - _ascHelixFill = false; + _helixFill = false; + _lastPageWasGql = false; await getStreams(); } @@ -560,5 +582,5 @@ abstract class ListStoreBase with Store { /// [ListType.followed] is the list of streams that the user is following. /// [ListType.top] is the list of top streams. /// [ListType.category] is the list of streams under a category. -/// [ListType.tag] is streams matching a freeform tag (Helix filter; GQL ASC first page). +/// [ListType.tag] is streams matching a freeform tag (GQL sort + tags; Helix fallback). enum ListType { followed, top, category, tag } diff --git a/lib/screens/settings/stores/user_store.dart b/lib/screens/settings/stores/user_store.dart index 058ed886..de29e1da 100644 --- a/lib/screens/settings/stores/user_store.dart +++ b/lib/screens/settings/stores/user_store.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/models/user.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -129,40 +130,98 @@ abstract class UserStoreBase with Store { required String gqlToken, required bool follow, }) async { - final deviceId = await _ensureDeviceId(); - - Future run({String? integrityToken}) { - if (follow) { - return twitchGqlApi.followUser( + await runWithGqlIntegrity( + gqlToken: gqlToken, + action: ({authToken, integrityToken, deviceId}) { + if (follow) { + return twitchGqlApi.followUser( + targetId: targetId, + authToken: authToken!, + integrityToken: integrityToken, + deviceId: deviceId, + ); + } + return twitchGqlApi.unfollowUser( targetId: targetId, - authToken: gqlToken, + authToken: authToken!, integrityToken: integrityToken, deviceId: deviceId, ); - } - return twitchGqlApi.unfollowUser( - targetId: targetId, - authToken: gqlToken, - integrityToken: integrityToken, - deviceId: deviceId, - ); - } + }, + ); + } + + /// Runs a GQL call with optional Client-Integrity; refreshes once on + /// [IntegrityCheckException] when a web [gqlToken] is available. + /// + /// Dio-minted tokens that are `is_bad_bot` are discarded; callers should + /// then capture credentials via a WebView and [applyWebIntegrity]. + Future runWithGqlIntegrity({ + required String? gqlToken, + required Future Function({ + required String? authToken, + required String? integrityToken, + required String? deviceId, + }) action, + }) async { + final deviceId = await _ensureDeviceId(); + final authToken = + gqlToken != null && gqlToken.isNotEmpty ? gqlToken : null; + + Future run(String? integrityToken) => action( + authToken: authToken, + integrityToken: integrityToken, + deviceId: _deviceId ?? deviceId, + ); try { - await run(integrityToken: _validIntegrityToken()); + return await run(_validIntegrityToken()); } on IntegrityCheckException { - final integrity = await twitchGqlApi.fetchIntegrityToken( - authToken: gqlToken, - deviceId: deviceId, - ); - _integrityToken = integrity.token; - _integrityExpirationMs = integrity.expirationMs; - await run(integrityToken: integrity.token); + if (authToken == null) { + clearIntegrity(); + rethrow; + } + try { + final integrity = await twitchGqlApi.fetchIntegrityToken( + authToken: authToken, + deviceId: _deviceId ?? deviceId, + ); + _integrityToken = integrity.token; + _integrityExpirationMs = integrity.expirationMs; + return await run(integrity.token); + } on IntegrityCheckException { + clearIntegrity(); + rethrow; + } } } + /// Stores WebView-captured integrity (must match [GqlIntegrityCredentials.deviceId]). + Future applyWebIntegrity(GqlIntegrityCredentials credentials) async { + if (TwitchIntegrity.isBadBot(credentials.integrityToken)) { + clearIntegrity(); + throw const IntegrityCheckException( + 'Twitch integrity token flagged as bad bot', + ); + } + _integrityToken = credentials.integrityToken; + _integrityExpirationMs = credentials.expirationMs; + _deviceId = credentials.deviceId; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_deviceIdKey, credentials.deviceId); + } + + void clearIntegrity() { + _integrityToken = null; + _integrityExpirationMs = 0; + } + String? _validIntegrityToken() { if (_integrityToken == null) return null; + if (TwitchIntegrity.isBadBot(_integrityToken!)) { + clearIntegrity(); + return null; + } if (DateTime.now().millisecondsSinceEpoch >= _integrityExpirationMs - 60000) { return null; } @@ -286,8 +345,7 @@ abstract class UserStoreBase with Store { _blockedUsers.clear(); _moderatedChannels.clear(); _followCache.clear(); - _integrityToken = null; - _integrityExpirationMs = 0; + clearIntegrity(); _disposeReaction?.call(); _disposeReaction = null; } diff --git a/lib/widgets/integrity_webview_dialog.dart b/lib/widgets/integrity_webview_dialog.dart new file mode 100644 index 00000000..9d9d9054 --- /dev/null +++ b/lib/widgets/integrity_webview_dialog.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +/// Captures a browser-valid `Client-Integrity` header by loading Twitch in a +/// WebView (Dio `/integrity` tokens are almost always `is_bad_bot`). +/// +/// Returns credentials, or null if the user cancels / capture times out. +Future showIntegrityWebViewDialog({ + required BuildContext context, + required String gqlToken, +}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => IntegrityWebViewDialog(gqlToken: gqlToken), + ); +} + +class IntegrityWebViewDialog extends StatefulWidget { + final String gqlToken; + + const IntegrityWebViewDialog({super.key, required this.gqlToken}); + + @override + State createState() => _IntegrityWebViewDialogState(); +} + +class _IntegrityWebViewDialogState extends State { + static const _captureTimeout = Duration(seconds: 45); + + late final WebViewController _controller; + Timer? _timeout; + var _captured = false; + var _status = 'Verifying with Twitch…'; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setUserAgent( + Platform.isIOS + ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 ' + 'Mobile/15E148 Safari/604.1' + : 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/135.0.0.0 Mobile Safari/537.36', + ) + ..addJavaScriptChannel( + 'FrostyIntegrity', + onMessageReceived: _onIntegrityMessage, + ) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (_) => _injectHooks(), + onPageFinished: (_) => _injectHooks(), + ), + ); + + _prepareAndLoad(); + _timeout = Timer(_captureTimeout, () { + if (!mounted || _captured) return; + setState(() => _status = 'Timed out waiting for Twitch. Try again.'); + }); + } + + Future _prepareAndLoad() async { + try { + final cookies = WebViewCookieManager(); + await cookies.setCookie( + WebViewCookie( + name: 'auth-token', + value: widget.gqlToken, + domain: '.twitch.tv', + ), + ); + } catch (e) { + debugPrint('IntegrityWebView: cookie set failed: $e'); + } + + // Same entry as Xtra: with auth-token set, Twitch redirects and emits GQL + // requests that carry a browser-valid Client-Integrity header. + await _controller.loadRequest(Uri.parse('https://www.twitch.tv/login')); + } + + Future _injectHooks() async { + // Hook fetch + XHR so we see Client-Integrity on Twitch's own GQL traffic. + const script = r''' +(function () { + if (window.__frostyIntegrityHooked) return; + window.__frostyIntegrityHooked = true; + + function report(headers) { + if (!headers || !window.FrostyIntegrity) return; + var integrity = headers['Client-Integrity'] || headers['client-integrity']; + if (!integrity) return; + var deviceId = headers['X-Device-Id'] || headers['x-device-id'] || ''; + var clientId = headers['Client-ID'] || headers['Client-Id'] || headers['client-id'] || ''; + window.FrostyIntegrity.postMessage(JSON.stringify({ + integrity: integrity, + deviceId: deviceId, + clientId: clientId + })); + } + + function headersToObject(headers) { + var out = {}; + if (!headers) return out; + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + headers.forEach(function (value, key) { out[key] = value; }); + return out; + } + if (typeof headers === 'object') { + Object.keys(headers).forEach(function (key) { out[key] = headers[key]; }); + } + return out; + } + + var originalFetch = window.fetch; + window.fetch = function (input, init) { + try { + report(headersToObject(init && init.headers)); + } catch (e) {} + return originalFetch.apply(this, arguments); + }; + + var originalOpen = XMLHttpRequest.prototype.open; + var originalSetHeader = XMLHttpRequest.prototype.setRequestHeader; + var originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function () { + this.__frostyHeaders = {}; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.setRequestHeader = function (key, value) { + try { this.__frostyHeaders[key] = value; } catch (e) {} + return originalSetHeader.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function () { + try { report(this.__frostyHeaders || {}); } catch (e) {} + return originalSend.apply(this, arguments); + }; +})(); +'''; + + try { + await _controller.runJavaScript(script); + } catch (e) { + debugPrint('IntegrityWebView: hook inject failed: $e'); + } + } + + void _onIntegrityMessage(JavaScriptMessage message) { + if (_captured || !mounted) return; + + try { + final map = jsonDecode(message.message) as Map; + final integrity = map['integrity'] as String?; + if (integrity == null || integrity.isEmpty) return; + if (TwitchIntegrity.isBadBot(integrity)) { + debugPrint('IntegrityWebView: ignoring bad_bot token'); + return; + } + + final fromHeader = map['deviceId'] as String?; + final fromToken = TwitchIntegrity.deviceId(integrity); + final deviceId = (fromHeader != null && fromHeader.isNotEmpty) + ? fromHeader + : (fromToken ?? ''); + if (deviceId.isEmpty) { + debugPrint('IntegrityWebView: missing device id'); + return; + } + + final expiration = TwitchIntegrity.expirationMs(integrity) ?? + DateTime.now().millisecondsSinceEpoch + const Duration(hours: 16).inMilliseconds; + + _captured = true; + _timeout?.cancel(); + Navigator.of(context).pop( + GqlIntegrityCredentials( + integrityToken: integrity, + deviceId: deviceId, + expirationMs: expiration, + ), + ); + } catch (e) { + debugPrint('IntegrityWebView: parse failed: $e'); + } + } + + @override + void dispose() { + _timeout?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final height = MediaQuery.sizeOf(context).height * 0.55; + return AlertDialog( + title: const Text('Confirm with Twitch'), + content: SizedBox( + width: double.maxFinite, + height: height.clamp(280.0, 520.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(_status), + const SizedBox(height: 12), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: WebViewWidget(controller: _controller), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + } +} diff --git a/lib/widgets/user_actions_modal.dart b/lib/widgets/user_actions_modal.dart index f1d1c8fa..fad22c21 100644 --- a/lib/widgets/user_actions_modal.dart +++ b/lib/widgets/user_actions_modal.dart @@ -7,6 +7,7 @@ import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:frosty/widgets/blurred_container.dart'; import 'package:frosty/widgets/frosty_dialog.dart'; +import 'package:frosty/widgets/integrity_webview_dialog.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -106,50 +107,64 @@ class _UserActionsModalState extends State { setState(() => _followActionInProgress = true); try { - if (following) { - await widget.authStore.user.unfollow( - targetId: widget.userId, - gqlToken: gqlToken, - ); - } else { - await widget.authStore.user.follow( - targetId: widget.userId, - gqlToken: gqlToken, + await _runFollowMutation(following: following, gqlToken: gqlToken); + } on IntegrityCheckException { + if (!mounted) return; + // Dio /integrity is almost always bad_bot — mint via WebView like Xtra. + final credentials = await showIntegrityWebViewDialog( + context: context, + gqlToken: gqlToken, + ); + if (!mounted) return; + if (credentials == null) { + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: AlertMessage( + message: 'Follow cancelled — Twitch verification required', + centered: false, + ), + ), ); + return; } - if (!mounted) return; - - final nowFollowing = !following; - setState(() { - _isFollowing = nowFollowing; - _followActionInProgress = false; - }); - widget.onFollowChanged?.call(nowFollowing); - - final messenger = ScaffoldMessenger.maybeOf(context); - Navigator.pop(context); - messenger?.showSnackBar( - SnackBar( - content: AlertMessage( - message: nowFollowing - ? 'Followed ${widget.name}' - : 'Unfollowed ${widget.name}', - centered: false, + try { + await widget.authStore.user.applyWebIntegrity(credentials); + await _runFollowMutation(following: following, gqlToken: gqlToken); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage(message: e.message, centered: false), ), - ), - ); + ); + return; + } catch (_) { + if (!mounted) return; + setState(() => _followActionInProgress = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: AlertMessage( + message: following + ? 'Could not unfollow ${widget.name}' + : 'Could not follow ${widget.name}', + centered: false, + ), + ), + ); + return; + } } on ApiException catch (e) { if (!mounted) return; setState(() => _followActionInProgress = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: AlertMessage( - message: e.message, - centered: false, - ), + content: AlertMessage(message: e.message, centered: false), ), ); + return; } catch (_) { if (!mounted) return; setState(() => _followActionInProgress = false); @@ -163,6 +178,46 @@ class _UserActionsModalState extends State { ), ), ); + return; + } + + if (!mounted) return; + + final nowFollowing = !following; + setState(() { + _isFollowing = nowFollowing; + _followActionInProgress = false; + }); + widget.onFollowChanged?.call(nowFollowing); + + final messenger = ScaffoldMessenger.maybeOf(context); + Navigator.pop(context); + messenger?.showSnackBar( + SnackBar( + content: AlertMessage( + message: nowFollowing + ? 'Followed ${widget.name}' + : 'Unfollowed ${widget.name}', + centered: false, + ), + ), + ); + } + + Future _runFollowMutation({ + required bool following, + required String gqlToken, + }) async { + if (following) { + await widget.authStore.user.unfollow( + targetId: widget.userId, + gqlToken: gqlToken, + ); + } else { + await widget.authStore.user.follow( + targetId: widget.userId, + gqlToken: gqlToken, + ); } } diff --git a/test/apis/twitch_gql_follow_test.dart b/test/apis/twitch_gql_follow_test.dart index 0e4bb171..00efa120 100644 --- a/test/apis/twitch_gql_follow_test.dart +++ b/test/apis/twitch_gql_follow_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/apis/base_api_client.dart'; @@ -69,4 +71,32 @@ void main() { await api.unfollowUser(targetId: '999', authToken: 'web-token'); }); }); + + group('fetchIntegrityToken', () { + test('rejects bad_bot tokens', () async { + final payload = base64Url.encode( + utf8.encode( + jsonEncode({ + 'is_bad_bot': 'true', + 'device_id': 'dev', + 'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko', + }), + ), + ); + + dioAdapter.onPost( + 'https://gql.twitch.tv/integrity', + (server) => server.reply(200, { + 'token': 'v4.public.$payload.sig', + 'expiration': DateTime.now().millisecondsSinceEpoch + 600000, + }), + data: Matchers.any, + ); + + await expectLater( + api.fetchIntegrityToken(authToken: 'web', deviceId: 'dev'), + throwsA(isA()), + ); + }); + }); } diff --git a/test/apis/twitch_gql_streams_test.dart b/test/apis/twitch_gql_streams_test.dart new file mode 100644 index 00000000..00888e9f --- /dev/null +++ b/test/apis/twitch_gql_streams_test.dart @@ -0,0 +1,129 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; + +void main() { + late Dio dio; + late DioAdapter dioAdapter; + late TwitchGqlApi api; + + setUp(() { + dio = Dio(BaseOptions()); + dioAdapter = DioAdapter(dio: dio); + api = TwitchGqlApi(dio); + }); + + Map streamEdge({ + required String id, + required String login, + required int viewers, + String? cursor, + }) { + return { + 'cursor': cursor ?? 'cursor-$id', + 'node': { + 'id': id, + 'title': '$login live', + 'viewersCount': viewers, + 'createdAt': '2026-01-01T00:00:00Z', + 'language': 'en', + 'game': {'id': '1', 'name': 'Game'}, + 'broadcaster': { + 'id': id, + 'login': login, + 'displayName': login, + }, + }, + }; + } + + group('getStreams', () { + test('parses freeform tag page and cursor', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'streams': { + 'edges': [ + streamEdge(id: '1', login: 'a', viewers: 10), + streamEdge(id: '2', login: 'b', viewers: 5), + ], + 'pageInfo': {'hasNextPage': true}, + }, + }, + }), + data: Matchers.any, + ); + + final page = await api.getStreams( + freeformTags: ['Deutsch'], + sort: StreamViewerSort.viewersAsc, + ); + + expect(page.data.map((s) => s.userLogin), ['a', 'b']); + expect(page.data.map((s) => s.viewerCount), [10, 5]); + expect(page.pagination['cursor'], 'cursor-2'); + }); + + test('throws IntegrityCheckException on integrity errors', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'errors': [ + {'message': 'failed integrity check'}, + ], + 'data': {'streams': null}, + }), + data: Matchers.any, + ); + + await expectLater( + api.getStreams( + freeformTags: ['Deutsch'], + cursor: 'page-2', + authToken: 'web', + integrityToken: 'stale', + deviceId: 'device', + ), + throwsA(isA()), + ); + }); + + test('sends Client-Integrity and X-Device-Id when provided', () async { + dioAdapter.onPost( + 'https://gql.twitch.tv/gql', + (server) => server.reply(200, { + 'data': { + 'streams': { + 'edges': [streamEdge(id: '1', login: 'a', viewers: 1)], + 'pageInfo': {'hasNextPage': false}, + }, + }, + }), + data: Matchers.any, + headers: { + 'Client-ID': Matchers.any, + 'Content-Type': Matchers.any, + 'User-Agent': Matchers.any, + 'Authorization': 'OAuth web-token', + 'X-Device-Id': 'dev-123', + 'Client-Integrity': 'integrity-token', + }, + ); + + final page = await api.getStreams( + freeformTags: ['English'], + sort: StreamViewerSort.viewersDesc, + cursor: 'after-1', + authToken: 'web-token', + integrityToken: 'integrity-token', + deviceId: 'dev-123', + ); + + expect(page.data, hasLength(1)); + expect(page.pagination, isEmpty); + }); + }); +} diff --git a/test/apis/twitch_integrity_test.dart b/test/apis/twitch_integrity_test.dart new file mode 100644 index 00000000..a07ec031 --- /dev/null +++ b/test/apis/twitch_integrity_test.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; + +String _tokenFor(Map payload) { + final encoded = base64Url.encode(utf8.encode(jsonEncode(payload))); + return 'v4.public.$encoded.fakesig'; +} + +void main() { + group('TwitchIntegrity', () { + test('detects is_bad_bot string flag', () { + final token = _tokenFor({ + 'client_id': 'kimne78kx3ncx6brgo4mv6wki5h1ko', + 'device_id': 'abc123', + 'is_bad_bot': 'true', + 'exp': '2099-01-01T00:00:00Z', + }); + + expect(TwitchIntegrity.isBadBot(token), isTrue); + expect(TwitchIntegrity.deviceId(token), 'abc123'); + expect(TwitchIntegrity.expirationMs(token), isNotNull); + }); + + test('detects is_bad_bot bool flag', () { + final token = _tokenFor({ + 'is_bad_bot': true, + 'device_id': 'dev', + }); + expect(TwitchIntegrity.isBadBot(token), isTrue); + }); + + test('returns false when not flagged', () { + final token = _tokenFor({ + 'is_bad_bot': 'false', + 'device_id': 'dev', + }); + expect(TwitchIntegrity.isBadBot(token), isFalse); + }); + }); +} diff --git a/test/screens/home/stream_list/stream_list_store_test.dart b/test/screens/home/stream_list/stream_list_store_test.dart new file mode 100644 index 00000000..1a066c66 --- /dev/null +++ b/test/screens/home/stream_list/stream_list_store_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:frosty/apis/base_api_client.dart'; +import 'package:frosty/apis/twitch_api.dart'; +import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/models/stream.dart'; +import 'package:frosty/models/user.dart'; +import 'package:frosty/screens/home/stream_list/stream_list_store.dart'; +import 'package:frosty/screens/settings/stores/auth_store.dart'; +import 'package:frosty/screens/settings/stores/settings_store.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockTwitchApi extends Mock implements TwitchApi {} + +class MockTwitchGqlApi extends Mock implements TwitchGqlApi {} + +void main() { + setUpAll(() { + registerFallbackValue(StreamViewerSort.viewersDesc); + registerFallbackValue([]); + }); + + late MockTwitchApi api; + late MockTwitchGqlApi gqlApi; + late AuthStore authStore; + late SettingsStore settingsStore; + + StreamTwitch stream(String id, int viewers) => StreamTwitch( + id, + 'user$id', + 'User$id', + 'game', + 'Game', + 'title', + viewers, + '2026-01-01T00:00:00Z', + 'https://cdn/live_user_$id-{width}x{height}.jpg', + ); + + Future waitUntilIdle(ListStore store) async { + await Future.delayed(Duration.zero); + for (var i = 0; i < 50 && store.isLoading; i++) { + await Future.delayed(const Duration(milliseconds: 5)); + } + } + + setUp(() { + SharedPreferences.setMockInitialValues({}); + api = MockTwitchApi(); + gqlApi = MockTwitchGqlApi(); + authStore = AuthStore(twitchApi: api, twitchGqlApi: gqlApi); + settingsStore = SettingsStore()..streamLanguages = []; + + when(() => api.getUserInfo()).thenAnswer( + (_) async => const UserTwitch( + '123', + 'me', + 'Me', + 'https://cdn/me.png', + ), + ); + when(() => api.getUserBlockedList(id: any(named: 'id'))).thenAnswer( + (_) async => [], + ); + }); + + group('ListStore tag GQL pagination', () { + test('keeps server ASC order across GQL pages', () async { + when( + () => gqlApi.getStreams( + freeformTags: any(named: 'freeformTags'), + languages: any(named: 'languages'), + sort: any(named: 'sort'), + cursor: any(named: 'cursor'), + first: any(named: 'first'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((invocation) async { + final cursor = invocation.namedArguments[#cursor] as String?; + if (cursor == null) { + return StreamsTwitch( + [stream('1', 3), stream('2', 7)], + const {'cursor': 'c1'}, + ); + } + expect(cursor, 'c1'); + return StreamsTwitch( + [stream('3', 12), stream('4', 20)], + const {}, + ); + }); + + settingsStore.streamViewerSort = StreamViewerSort.viewersAsc; + + final store = ListStore( + authStore: authStore, + settingsStore: settingsStore, + twitchApi: api, + twitchGqlApi: gqlApi, + listType: ListType.tag, + freeformTag: 'Deutsch', + ); + + await waitUntilIdle(store); + + expect(store.streams.map((s) => s.userId), ['1', '2']); + expect(store.hasMore, isTrue); + + await store.getStreams(); + await waitUntilIdle(store); + + expect(store.streams.map((s) => s.userId), ['1', '2', '3', '4']); + expect(store.streams.map((s) => s.viewerCount), [3, 7, 12, 20]); + expect(store.hasMore, isFalse); + verifyNever( + () => api.getDirectoryStreams( + languages: any(named: 'languages'), + gameId: any(named: 'gameId'), + freeformTag: any(named: 'freeformTag'), + cursor: any(named: 'cursor'), + minCount: any(named: 'minCount'), + maxPages: any(named: 'maxPages'), + ), + ); + + store.dispose(); + }); + + test('falls back to Helix when GQL page-2 integrity fails', () async { + var gqlCalls = 0; + when( + () => gqlApi.getStreams( + freeformTags: any(named: 'freeformTags'), + languages: any(named: 'languages'), + sort: any(named: 'sort'), + cursor: any(named: 'cursor'), + first: any(named: 'first'), + authToken: any(named: 'authToken'), + integrityToken: any(named: 'integrityToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer((invocation) async { + gqlCalls++; + final cursor = invocation.namedArguments[#cursor] as String?; + if (cursor == null) { + return StreamsTwitch( + [stream('1', 100)], + const {'cursor': 'c1'}, + ); + } + throw const IntegrityCheckException('failed integrity check'); + }); + + when( + () => api.getDirectoryStreams( + languages: any(named: 'languages'), + gameId: any(named: 'gameId'), + freeformTag: any(named: 'freeformTag'), + cursor: any(named: 'cursor'), + minCount: any(named: 'minCount'), + maxPages: any(named: 'maxPages'), + ), + ).thenAnswer( + (_) async => StreamsTwitch( + [stream('1', 100), stream('9', 50)], + const {}, + ), + ); + + final store = ListStore( + authStore: authStore, + settingsStore: settingsStore, + twitchApi: api, + twitchGqlApi: gqlApi, + listType: ListType.tag, + freeformTag: 'Deutsch', + ); + + await waitUntilIdle(store); + expect(store.streams.map((s) => s.userId), ['1']); + + await store.getStreams(); + await waitUntilIdle(store); + + expect(gqlCalls, greaterThanOrEqualTo(2)); + expect(store.streams.map((s) => s.userId), ['1', '9']); + verify( + () => api.getDirectoryStreams( + languages: any(named: 'languages'), + gameId: any(named: 'gameId'), + freeformTag: any(named: 'freeformTag'), + cursor: any(named: 'cursor'), + minCount: any(named: 'minCount'), + maxPages: any(named: 'maxPages'), + ), + ).called(1); + + store.dispose(); + }); + }); +} diff --git a/test/screens/settings/stores/user_store_test.dart b/test/screens/settings/stores/user_store_test.dart index 9d11c922..4897d3ad 100644 --- a/test/screens/settings/stores/user_store_test.dart +++ b/test/screens/settings/stores/user_store_test.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:frosty/apis/base_api_client.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; +import 'package:frosty/apis/twitch_integrity.dart'; import 'package:frosty/models/user.dart'; import 'package:frosty/screens/settings/stores/user_store.dart'; import 'package:mocktail/mocktail.dart'; @@ -178,5 +181,88 @@ void main() { expect(userStore.cachedIsFollowing('999'), isFalse); }); + + test('runWithGqlIntegrity refreshes token and retries', () async { + var calls = 0; + when( + () => gqlApi.fetchIntegrityToken( + authToken: any(named: 'authToken'), + deviceId: any(named: 'deviceId'), + ), + ).thenAnswer( + (_) async => ( + token: 'fresh-integrity', + expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, + ), + ); + + final userStore = makeStore(hasModeratorScopes: false); + final result = await userStore.runWithGqlIntegrity( + gqlToken: 'web-token', + action: ({authToken, integrityToken, deviceId}) async { + calls++; + expect(authToken, 'web-token'); + expect(deviceId, isNotEmpty); + if (calls == 1) { + throw const IntegrityCheckException('failed integrity check'); + } + expect(integrityToken, 'fresh-integrity'); + return 'ok'; + }, + ); + + expect(result, 'ok'); + expect(calls, 2); + verify( + () => gqlApi.fetchIntegrityToken( + authToken: 'web-token', + deviceId: any(named: 'deviceId'), + ), + ).called(1); + }); + + test('runWithGqlIntegrity rethrows when no gqlToken', () async { + final userStore = makeStore(hasModeratorScopes: false); + + await expectLater( + userStore.runWithGqlIntegrity( + gqlToken: null, + action: ({authToken, integrityToken, deviceId}) async { + throw const IntegrityCheckException('failed integrity check'); + }, + ), + throwsA(isA()), + ); + verifyNever( + () => gqlApi.fetchIntegrityToken( + authToken: any(named: 'authToken'), + deviceId: any(named: 'deviceId'), + ), + ); + }); + + test('applyWebIntegrity stores device id for later GQL calls', () async { + final userStore = makeStore(hasModeratorScopes: false); + await userStore.applyWebIntegrity( + GqlIntegrityCredentials( + integrityToken: 'v4.public.${base64Url.encode(utf8.encode(jsonEncode({ + 'is_bad_bot': 'false', + 'device_id': 'webview-device', + })))}.sig', + deviceId: 'webview-device', + expirationMs: DateTime.now().millisecondsSinceEpoch + 600000, + ), + ); + + final deviceId = await userStore.runWithGqlIntegrity( + gqlToken: 'web-token', + action: ({authToken, integrityToken, deviceId}) async { + expect(deviceId, 'webview-device'); + expect(integrityToken, isNotNull); + return deviceId!; + }, + ); + expect(deviceId, 'webview-device'); + }); }); }