diff --git a/.fvmrc b/.fvmrc index a8ea8515..fc9e690f 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.38.9" + "flutter": "3.44.1" } \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86b914c8..8fd67013 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.9' + flutter-version: '3.44.1' channel: stable cache: true @@ -45,5 +45,7 @@ jobs: - name: Test run: flutter test - - name: Build debug APK - run: flutter build apk --debug --dart-define=CLIENT_ID=dummy --dart-define=SECRET=dummy + - name: Build release APK + run: flutter build apk --release --dart-define=CLIENT_ID=dummy --dart-define=SECRET=dummy + env: + ALLOW_DEBUG_SIGNED_RELEASE: '1' diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml index c63d42ec..29d49970 100644 --- a/.github/workflows/gh-release.yml +++ b/.github/workflows/gh-release.yml @@ -44,7 +44,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.38.9' + flutter-version: '3.44.1' channel: stable cache: true diff --git a/.vscode/settings.json b/.vscode/settings.json index 28739dee..2058f2b6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.38.9" + "dart.flutterSdkPath": ".fvm/versions/3.44.1" } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 3104051d..a6d9d7a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,4 +50,6 @@ dart run build_runner build --delete-conflicting-outputs # Same, but ## Commits -Lowercase, no prefixes (e.g., `fix landscape bottom padding in chat bottom bar`). Keep commits tightly scoped. +Conventional commits: `type(scope): description` — lowercase, no trailing period, tightly scoped (e.g., `fix(chat): correct landscape bottom padding in bottom bar`). Append `!` before the `:` for breaking changes (e.g., `feat(api)!: drop legacy auth flow`). + +Common types: `feat`, `fix`, `chore`, `refactor`, `docs`, `test`, `perf`, `style`. Scope is the affected area (e.g., `chat`, `video`, `api`). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c8a8a0cb..b420cbb3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -47,7 +47,16 @@ android { buildTypes { release { - signingConfig = signingConfigs.getByName("release") + // Use the real upload key when key.properties is present (release + // workflows create it from secrets). Allow debug signing only when a CI + // compile-only check explicitly opts in via ALLOW_DEBUG_SIGNED_RELEASE; + // otherwise fail loudly so a real release can never be silently + // debug-signed if the keystore step is ever missing. + signingConfig = when { + keystorePropertiesFile.exists() -> signingConfigs.getByName("release") + System.getenv("ALLOW_DEBUG_SIGNED_RELEASE") != null -> signingConfigs.getByName("debug") + else -> error("key.properties missing - set ALLOW_DEBUG_SIGNED_RELEASE=1 for compile-only builds") + } } } } diff --git a/lib/models/irc.dart b/lib/models/irc.dart index d0ca7375..1d9623cc 100644 --- a/lib/models/irc.dart +++ b/lib/models/irc.dart @@ -959,22 +959,25 @@ class IRCMessage { // Obtain the index to break off the tags. final tagAndIrcMessageDivider = whole.indexOf(' '); - // Get the tags substring and escape characters. - // IRC messages escape spaces with \s. - final tags = whole - .substring(1, tagAndIrcMessageDivider) - .replaceAll('\\s', ' '); - - // Next, parse and map the tags. + // Parse the IRCv3 tags into a map. The tags section is everything between + // the leading '@' and the first space. We split on the *raw* string: a + // literal ';' is always a separator because escaped semicolons inside a + // value are encoded as `\:` (unescaped per-value below). final mappedTags = {}; + final rawTags = whole.substring(1, tagAndIrcMessageDivider); - // Loop through each tag and store their key value pairs into the map. - for (final tag in tags.split(';')) { - // Skip if the tag has no value. - if (tag.endsWith('=')) continue; + for (final tag in rawTags.split(';')) { + // Split on the first '=' only; IRCv3 tag values may contain '='. + final equalsIndex = tag.indexOf('='); - final tagSplit = tag.split('='); - mappedTags[tagSplit[0]] = tagSplit[1]; + // Skip valueless tags. Per the IRCv3 spec a missing value (bare `key`) + // and an empty value (`key=`) are equivalent; live Twitch sends the + // latter, the recent-messages API the former. + if (equalsIndex == -1 || equalsIndex == tag.length - 1) continue; + + mappedTags[tag.substring(0, equalsIndex)] = _unescapeTagValue( + tag.substring(equalsIndex + 1), + ); } // Now we'll parse the message itself. @@ -990,9 +993,19 @@ class IRCMessage { // If the username exists, set it. // tmi.twitch.tv means the message was sent by Twitch rather than a user, so will be irrelevant. - final String? user = splitMessage[0] == _twitchIrcServer - ? mappedTags['login'] - : splitMessage[0].substring(0, splitMessage[0].indexOf('!')); + final prefix = splitMessage[0]; + final String? user; + if (prefix == _twitchIrcServer) { + user = mappedTags['login']; + } else { + // Normal prefixes are `nick!user@host`. Fall back to the login tag (or + // null) if the expected `!` is absent so an unexpected prefix shape can + // never throw a RangeError and take down the whole message. + final bangIndex = prefix.indexOf('!'); + user = bangIndex == -1 + ? mappedTags['login'] + : prefix.substring(0, bangIndex); + } // If there is an associated message, set it. String? message; @@ -1153,6 +1166,50 @@ class IRCMessage { ); } + /// Unescapes an IRCv3 message-tag value. + /// + /// The spec defines these sequences: `\:` to `;`, `\s` to space, `\\` to `\`, + /// `\r` to CR, `\n` to LF. Any other `\x` collapses to `x`, and a lone trailing + /// backslash is dropped. Done in a single pass so sequences can't be + /// double-unescaped (e.g. `\\s` correctly yields `\s`, not a space). + static String _unescapeTagValue(String value) { + // Fast path: the vast majority of tag values contain no escape sequences. + if (!value.contains('\\')) return value; + + final buffer = StringBuffer(); + for (var i = 0; i < value.length; i++) { + if (value[i] != '\\') { + buffer.write(value[i]); + continue; + } + + // A trailing backslash with nothing after it is dropped. + if (i + 1 == value.length) break; + + switch (value[i + 1]) { + case ':': + buffer.write(';'); + break; + case 's': + buffer.write(' '); + break; + case '\\': + buffer.write('\\'); + break; + case 'r': + buffer.write('\r'); + break; + case 'n': + buffer.write('\n'); + break; + default: + buffer.write(value[i + 1]); + } + i++; // Skip the escaped character we just consumed. + } + return buffer.toString(); + } + factory IRCMessage.createNotice({ required String message, VoidCallback? actionCallback, diff --git a/lib/screens/channel/chat/stores/chat_assets_store.dart b/lib/screens/channel/chat/stores/chat_assets_store.dart index c1ec05f7..2efd626c 100644 --- a/lib/screens/channel/chat/stores/chat_assets_store.dart +++ b/lib/screens/channel/chat/stores/chat_assets_store.dart @@ -290,6 +290,18 @@ abstract class ChatAssetsStoreBase with Store { await Future.wait(futures); } + /// Whether any shared chat participant assets have been merged in. + bool get hasLoadedSharedChatAssets => _loadedSharedChannelIds.isNotEmpty; + + /// Clears tracking of merged shared chat participant assets so that leaving + /// and later re-entering a shared chat session re-fetches cleanly. The caller + /// must re-fetch this channel's own assets afterwards (which replaces the + /// merged emote/badge maps). Must run within a MobX action. + void resetSharedChatAssets() { + _loadedSharedChannelIds.clear(); + sharedSevenTvSetIds.clear(); + } + /// Load and cache shared chat participants for a broadcaster. /// Returns the list of new participant broadcaster IDs (deduped). @action diff --git a/lib/screens/channel/chat/stores/chat_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index 534d11de..caf44fb7 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -518,10 +518,19 @@ abstract class ChatStoreBase with Store { for (final message in data.trimRight().split('\r\n')) { // debugPrint('$message\n'); if (message.startsWith('@')) { - final parsedIRCMessage = IRCMessage.fromString( - message, - userLogin: auth.user.details?.login, - ); + // Parse defensively: a single malformed message must never abort the + // loop, or one bad frame would drop every other message batched with + // it (e.g. an entire recent-messages backlog). Skip and keep going. + final IRCMessage parsedIRCMessage; + try { + parsedIRCMessage = IRCMessage.fromString( + message, + userLogin: auth.user.details?.login, + ); + } catch (e) { + debugPrint('Failed to parse IRC message: $e'); + continue; + } if (parsedIRCMessage.user != null) { chatDetailsStore.chatUsers.add(parsedIRCMessage.user!); @@ -601,6 +610,16 @@ abstract class ChatStoreBase with Store { showFFZEmotes: settings.showFFZEmotes, showFFZBadges: settings.showFFZBadges, ); + } else if (wasShared && + !_isInSharedChatMode && + assetsStore.hasLoadedSharedChatAssets) { + // On leaving shared chat, drop the participants' merged emotes and + // badges and restore just this channel's assets; otherwise the + // other streamer's emote set and badges linger (#522). Guarded on + // having actually loaded shared assets so a flapping tag can't + // trigger redundant refetches. + assetsStore.resetSharedChatAssets(); + getAssets(); } messageBuffer.add(parsedIRCMessage); break; diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index a8538499..21b9a123 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -622,6 +622,8 @@ abstract class ChatTabsStoreBase with Store { // Primary tab (index 0) cannot be moved, and nothing can move before it if (oldIndex == 0 || newIndex == 0) return; + // onReorderItem already adjusts newIndex for the removed item, so no + // manual off-by-one correction is needed here. if (oldIndex == newIndex) return; final tab = _tabs.removeAt(oldIndex); diff --git a/lib/screens/channel/chat/widgets/chat_tabs.dart b/lib/screens/channel/chat/widgets/chat_tabs.dart index d3760702..cb24cdbf 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -141,16 +141,9 @@ class ChatTabs extends StatelessWidget { left: 12, right: showMerge ? 32 : 12, ), - // ignore: deprecated_member_use - onReorder: (oldIndex, newIndex) { + onReorderItem: (oldIndex, newIndex) { HapticFeedback.lightImpact(); - final adjustedNewIndex = oldIndex < newIndex - ? newIndex - 1 - : newIndex; - chatTabsStore.reorderTab( - oldIndex, - adjustedNewIndex, - ); + chatTabsStore.reorderTab(oldIndex, newIndex); }, proxyDecorator: (child, index, animation) { return Material( diff --git a/test/fixtures/irc_messages.dart b/test/fixtures/irc_messages.dart index 28624337..a5bcbfaa 100644 --- a/test/fixtures/irc_messages.dart +++ b/test/fixtures/irc_messages.dart @@ -9,6 +9,19 @@ const basicPrivmsg = 'tmi-sent-ts=1234567890123;turbo=0;user-id=67890;user-type= ' ':testuser!testuser@testuser.tmi.twitch.tv PRIVMSG #channel :Hello World'; +/// Exercises the full IRCv3 tag escape set plus a value that itself contains +/// '='. Uses a raw string so the backslashes are literal wire bytes. +const messageWithEscapedTagChars = + r'@a-space=one\stwo;a-semi=left\:right;a-slash=a\\b;a-equals=k=v;' + r'a-double=x\\sy;display-name=EscapeAll;mod=0 ' + r':escapeall!escapeall@escapeall.tmi.twitch.tv PRIVMSG #channel :hi'; + +/// A message whose prefix is not the usual `nick!user@host` form (no '!'). +/// The parser should fall back to the login tag instead of throwing. +const messageNoBangPrefix = + '@login=fallbackuser;mod=0;id=nobang-1 ' + ':some.server.example PRIVMSG #channel :server style'; + /// A chat message with emotes in the tags /// Note: emote positions are 0-indexed byte positions in the message /// "Kappa LUL" - Kappa is at 0-4, LUL is at 6-8 @@ -138,6 +151,16 @@ const historicalMessage = 'tmi-sent-ts=1234567890123;turbo=0;user-id=12121;user-type= ' ':histuser!histuser@histuser.tmi.twitch.tv PRIVMSG #channel :Historical message'; +/// A historical message as serialized by the recent-messages API, which emits +/// empty tags as bare keys (e.g. `emotes`, `flags`) instead of the `key=` form +/// that live Twitch IRC uses. +const historicalMessageBareTags = + '@badges=raging-wolf-helm/1;emotes;first-msg=0;tmi-sent-ts=1234567890123;' + 'flags;mod=0;returning-chatter=0;rm-received-ts=1234567890456;color=#1E90FF;' + 'id=hist-bare-123;room-id=12345;historical=1;user-type;turbo=0;subscriber=0;' + 'badge-info;display-name=BareUser;user-id=18181 ' + ':bareuser!bareuser@bareuser.tmi.twitch.tv PRIVMSG #channel :Bare tag message'; + /// A message from a shared chat session (source room different from current) const sharedChatMessage = '@badge-info=;badges=;color=#00BFFF;display-name=SharedUser;emotes=;' diff --git a/test/models/irc_test.dart b/test/models/irc_test.dart index 2228a54c..f37a39eb 100644 --- a/test/models/irc_test.dart +++ b/test/models/irc_test.dart @@ -27,6 +27,27 @@ void main() { expect(msg.message, 'test message'); }); + test('unescapes the full IRCv3 tag escape set', () { + final msg = IRCMessage.fromString(messageWithEscapedTagChars); + + expect(msg.tags['a-space'], 'one two'); + expect(msg.tags['a-semi'], 'left;right'); + expect(msg.tags['a-slash'], r'a\b'); + // Only the first '=' splits key/value; later '=' stays in the value. + expect(msg.tags['a-equals'], 'k=v'); + // `\\` consumes both backslashes; the following 's' is a literal letter, + // not a `\s` space escape (single-pass unescaping). + expect(msg.tags['a-double'], r'x\sy'); + }); + + test('falls back to login tag when prefix has no "!"', () { + // An unexpected prefix shape must not throw and drop the message. + final msg = IRCMessage.fromString(messageNoBangPrefix); + + expect(msg.user, 'fallbackuser'); + expect(msg.message, 'server style'); + }); + test('handles message with no color tag', () { final msg = IRCMessage.fromString(messageNoColor); @@ -223,6 +244,20 @@ void main() { expect(msg.tags['historical'], '1'); }); + test('parses historical message with bare (valueless) tags', () { + // The recent-messages API serializes empty tags as bare keys (e.g. + // `emotes`, `flags`) instead of `key=`. This previously threw a + // RangeError, silently dropping all recent messages. + final msg = IRCMessage.fromString(historicalMessageBareTags); + + expect(msg.tags['historical'], '1'); + expect(msg.tags['display-name'], 'BareUser'); + expect(msg.message, 'Bare tag message'); + // Bare tags should be absent from the map, not crash parsing. + expect(msg.tags.containsKey('emotes'), isFalse); + expect(msg.tags.containsKey('flags'), isFalse); + }); + test('parses shared chat message with source room', () { final msg = IRCMessage.fromString(sharedChatMessage);