From c27a37d52f37744fd948258696ece33fdbdd7ca9 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 21:53:53 -0700 Subject: [PATCH 1/9] fix recent messages by handling valueless and escaped irc tags The recent-messages API now serializes empty IRCv3 tags as bare keys (e.g. `emotes`) instead of `emotes=`, which threw a RangeError in the tag parser and silently dropped every recent message. Parse tags per the IRCv3 spec: treat bare and empty values as equivalent, split on the first `=` so values may contain `=`, and unescape values in a single pass (\: \s \ \r \n). Also guard user extraction against prefixes without `!`, and isolate per-message parse failures in _handleIRCData so one bad message can no longer abort an entire batch. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/models/irc.dart | 89 +++++++++++++++---- .../channel/chat/stores/chat_store.dart | 17 +++- test/fixtures/irc_messages.dart | 23 +++++ test/models/irc_test.dart | 35 ++++++++ 4 files changed, 144 insertions(+), 20 deletions(-) diff --git a/lib/models/irc.dart b/lib/models/irc.dart index 0cb0a6bf..c2bea5d2 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: `\:` → `;`, `\s` → space, `\\` → `\`, + /// `\r` → CR, `\n` → 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_store.dart b/lib/screens/channel/chat/stores/chat_store.dart index e8b5d607..ccc93d6c 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -519,10 +519,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!); 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); From e24491639dafab2c8fb43cc2ecfe2d832efaad7a Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:01:47 -0700 Subject: [PATCH 2/9] docs: adopt conventional commit format in AGENTS.md Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 2959400d..d9145970 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,4 +52,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`). From d2b4f0f39e026d1efe53826014b6bea99240af5a Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:01:47 -0700 Subject: [PATCH 3/9] chore(chat): migrate reorderable tab list to onReorderItem for flutter 3.44 ReorderableListView's onReorder was deprecated after Flutter 3.41 and fails flutter analyze on the CI's pinned 3.44.1 toolchain. Switch to onReorderItem, which adjusts newIndex for the removed item itself, and drop the now-redundant manual off-by-one correction in reorderTab. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/screens/channel/chat/stores/chat_tabs_store.dart | 6 ++---- lib/screens/channel/chat/widgets/chat_tabs.dart | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/screens/channel/chat/stores/chat_tabs_store.dart b/lib/screens/channel/chat/stores/chat_tabs_store.dart index 0fc54c46..8d3f1c8d 100644 --- a/lib/screens/channel/chat/stores/chat_tabs_store.dart +++ b/lib/screens/channel/chat/stores/chat_tabs_store.dart @@ -620,10 +620,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; - // ReorderableListView passes newIndex as if item was already removed - if (oldIndex < newIndex) { - newIndex -= 1; - } + // 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 500831b4..fe946202 100644 --- a/lib/screens/channel/chat/widgets/chat_tabs.dart +++ b/lib/screens/channel/chat/widgets/chat_tabs.dart @@ -144,7 +144,7 @@ class ChatTabs extends StatelessWidget { left: 12, right: showMerge ? 32 : 12, ), - onReorder: (oldIndex, newIndex) { + onReorderItem: (oldIndex, newIndex) { HapticFeedback.lightImpact(); chatTabsStore.reorderTab(oldIndex, newIndex); }, From a3c9ca88f8e7182aff78c5a205637507d20b605c Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:29:20 -0700 Subject: [PATCH 4/9] chore(deps): support flutter 3.44 stable (simple_icons, extended_text_field) The CI/release toolchain uses the latest stable Flutter, now 3.44.1, which broke two dependencies: - simple_icons 14.6.1 extends IconData, now a final class -> bump to 16.23.0. - extended_text_field 16.0.2 (latest pub release) vendors an EditableText copy referencing ExtendSelectionByPageIntent, removed in 3.44 -> point at a fork (tommyxchow/extended_text_field@76305e9) that drops the unused handler, pinned by commit SHA. Restores flutter analyze (no issues) and flutter test (all pass) on 3.44.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- pubspec.lock | 21 ++- pubspec.yaml | 505 ++++++++++++++++++++++++++------------------------- 2 files changed, 266 insertions(+), 260 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 6eb900ef..11a70b52 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -316,10 +316,11 @@ packages: extended_text_field: dependency: "direct main" description: - name: extended_text_field - sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d" - url: "https://pub.dev" - source: hosted + path: "." + ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" + resolved-ref: "76305e9b115f0b41dc0d23e7a4f651df454fea89" + url: "https://github.com/tommyxchow/extended_text_field.git" + source: git version: "16.0.2" extended_text_library: dependency: transitive @@ -788,10 +789,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1108,10 +1109,10 @@ packages: dependency: "direct main" description: name: simple_icons - sha256: "2ca3cd79c9f12e97a8588cae0f342609f19fd2e82315356cb09b5c4987ad0808" + sha256: "21246ffcaaf9f560695596093c2ecf9fa763fd112a15916fbe7d282932430bae" url: "https://pub.dev" source: hosted - version: "14.6.1" + version: "16.23.0" simple_pip_mode: dependency: "direct main" description: @@ -1242,10 +1243,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3be38265..f5f69184 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,250 +1,255 @@ -name: frosty -description: A Twitch client for iOS and Android. - -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 5.0.5+68 - -environment: - sdk: ^3.9.0 - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - json_annotation: ^4.6.0 - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - web_socket_channel: ^3.0.0 - flutter_secure_storage: ^9.2.2 - intl: ^0.20.1 - cached_network_image: ^3.1.0 - webview_flutter: ^4.2.0 - provider: ^6.0.1 - mobx: ^2.0.7+4 - flutter_mobx: ^2.0.0 - shared_preferences: ^2.0.8 - flutter_svg: ^2.0.5 - url_launcher: ^6.0.17 - package_info_plus: ^9.0.0 - collection: ^1.16.0 - flutter_cache_manager: ^3.3.0 - device_info_plus: ^12.3.0 - wakelock_plus: ^1.1.2 - simple_icons: ^14.6.1 - simple_pip_mode: - git: - url: https://github.com/tommyxchow/simple_pip_mode_flutter - firebase_core: ^4.1.0 - firebase_crashlytics: ^5.0.1 - firebase_performance: ^0.11.0+1 - firebase_analytics: ^12.0.1 - advanced_in_app_review: ^1.1.3 - webview_flutter_android: ^4.0.2 - webview_flutter_wkwebview: ^3.4.3 - photo_view: ^0.15.0 - flutter_markdown_plus: ^1.0.7 - flutter_colorpicker: ^1.1.0 - path_provider: ^2.1.5 - dio: ^5.9.0 - app_links: ^7.0.0 - extended_text_field: ^16.0.2 - native_device_orientation: ^2.1.0 - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 - build_runner: ^2.1.1 - json_serializable: ^6.0.0 - mobx_codegen: ^2.0.6 - flutter_native_splash: ^2.0.5 - flutter_launcher_icons: ^0.14.1 - mocktail: ^1.0.4 - http_mock_adapter: ^0.6.1 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - fonts: - - family: Inter - fonts: - - asset: assets/fonts/Inter-Thin.ttf - - asset: assets/fonts/Inter-ExtraLight.ttf - - asset: assets/fonts/Inter-Light.ttf - - asset: assets/fonts/Inter-Regular.ttf - - asset: assets/fonts/Inter-Medium.ttf - - asset: assets/fonts/Inter-SemiBold.ttf - - asset: assets/fonts/Inter-Bold.ttf - - asset: assets/fonts/Inter-ExtraBold.ttf - - asset: assets/fonts/Inter-Black.ttf - - asset: assets/fonts/Inter-ThinItalic.ttf - - asset: assets/fonts/Inter-ExtraLightItalic.ttf - - asset: assets/fonts/Inter-LightItalic.ttf - - asset: assets/fonts/Inter-Italic.ttf - - asset: assets/fonts/Inter-MediumItalic.ttf - - asset: assets/fonts/Inter-SemiBoldItalic.ttf - - asset: assets/fonts/Inter-BoldItalic.ttf - - asset: assets/fonts/Inter-ExtraBoldItalic.ttf - - asset: assets/fonts/Inter-BlackItalic.ttf - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - assets: - - assets/icons/logo.svg - - assets/fonts/ - - assets/release-notes.md - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package - -flutter_native_splash: - # This package generates native code to customize Flutter's default white native splash screen - # with background color and splash image. - # Customize the parameters below, and run the following command in the terminal: - # flutter pub run flutter_native_splash:create - # To restore Flutter's default white splash screen, run the following command in the terminal: - # flutter pub run flutter_native_splash:remove - - # color or background_image is the only required parameter. Use color to set the background - # of your splash screen to a solid color. Use background_image to set the background of your - # splash screen to a png image. This is useful for gradients. The image will be stretch to the - # size of the app. Only one parameter can be used, color and background_image cannot both be set. - # color: "#000000" - background_image: "assets/icons/bg.png" - - # Optional parameters are listed below. To enable a parameter, uncomment the line by removing - # the leading # character. - - # The image parameter allows you to specify an image used in the splash screen. It must be a - # png file and should be sized for 4x pixel density. - image: assets/icons/logo.png - - # This property allows you to specify an image used as branding in the splash screen. It must be - # a png file. Currently, it is only supported for Android and iOS. - # branding: assets/dart.png - - # Specify your branding image for dark mode. - #branding_dark: assets/dart_dark.png - - # To position the branding image at the bottom of the screen you can use bottom, bottomRight, - # and bottomLeft. The default values is bottom if not specified or specified something else. - # - # Make sure this content mode value should not be similar to android_gravity value and ios_content_mode - # value. - # branding_mode: bottom - - # The color_dark, background_image_dark, and image_dark are parameters that set the background - # and image when the device is in dark mode. If they are not specified, the app will use the - # parameters from above. If the image_dark parameter is specified, color_dark or - # background_image_dark must be specified. color_dark and background_image_dark cannot both be - # set. - #color_dark: "#042a49" - #background_image_dark: "assets/dark-background.png" - #image_dark: assets/splash-invert.png - - # The android, ios and web parameters can be used to disable generating a splash screen on a given - # platform. - #android: false - #ios: false - #web: false - - # The position of the splash image can be set with android_gravity, ios_content_mode, and - # web_image_mode parameters. All default to center. - # - # android_gravity can be one of the following Android Gravity (see - # https://developer.android.com/reference/android/view/Gravity): bottom, center, - # center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal, - # fill_vertical, left, right, start, or top. - #android_gravity: center - # - # ios_content_mode can be one of the following iOS UIView.ContentMode (see - # https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill, - # scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight, - # bottomLeft, or bottomRight. - #ios_content_mode: center - # - # web_image_mode can be one of the following modes: center, contain, stretch, and cover. - #web_image_mode: center - - # To hide the notification bar, use the fullscreen parameter. Has no affect in web since web - # has no notification bar. Defaults to false. - # NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads. - # To show the notification bar, add the following code to your Flutter app: - # WidgetsFlutterBinding.ensureInitialized(); - # SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]); - #fullscreen: true - - # If you have changed the name(s) of your info.plist file(s), you can specify the filename(s) - # with the info_plist_files parameter. Remove only the # characters in the three lines below, - # do not remove any spaces: - #info_plist_files: - # - 'ios/Runner/Info-Debug.plist' - # - 'ios/Runner/Info-Release.plist' - -flutter_launcher_icons: - image_path_android: "assets/icons/icon_android.png" - image_path_ios: "assets/icons/icon_ios.png" - android: true - ios: true - remove_alpha_ios: true +name: frosty +description: A Twitch client for iOS and Android. + +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 5.0.5+68 + +environment: + sdk: ^3.9.0 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + json_annotation: ^4.6.0 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + web_socket_channel: ^3.0.0 + flutter_secure_storage: ^9.2.2 + intl: ^0.20.1 + cached_network_image: ^3.1.0 + webview_flutter: ^4.2.0 + provider: ^6.0.1 + mobx: ^2.0.7+4 + flutter_mobx: ^2.0.0 + shared_preferences: ^2.0.8 + flutter_svg: ^2.0.5 + url_launcher: ^6.0.17 + package_info_plus: ^9.0.0 + collection: ^1.16.0 + flutter_cache_manager: ^3.3.0 + device_info_plus: ^12.3.0 + wakelock_plus: ^1.1.2 + simple_icons: ^16.23.0 + simple_pip_mode: + git: + url: https://github.com/tommyxchow/simple_pip_mode_flutter + firebase_core: ^4.1.0 + firebase_crashlytics: ^5.0.1 + firebase_performance: ^0.11.0+1 + firebase_analytics: ^12.0.1 + advanced_in_app_review: ^1.1.3 + webview_flutter_android: ^4.0.2 + webview_flutter_wkwebview: ^3.4.3 + photo_view: ^0.15.0 + flutter_markdown_plus: ^1.0.7 + flutter_colorpicker: ^1.1.0 + path_provider: ^2.1.5 + dio: ^5.9.0 + app_links: ^7.0.0 + # Fork pinned for Flutter 3.44 compatibility — upstream 16.0.2 is the latest + # pub release and references ExtendSelectionByPageIntent, removed in 3.44. + extended_text_field: + git: + url: https://github.com/tommyxchow/extended_text_field.git + ref: 76305e9b115f0b41dc0d23e7a4f651df454fea89 + native_device_orientation: ^2.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + build_runner: ^2.1.1 + json_serializable: ^6.0.0 + mobx_codegen: ^2.0.6 + flutter_native_splash: ^2.0.5 + flutter_launcher_icons: ^0.14.1 + mocktail: ^1.0.4 + http_mock_adapter: ^0.6.1 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + fonts: + - family: Inter + fonts: + - asset: assets/fonts/Inter-Thin.ttf + - asset: assets/fonts/Inter-ExtraLight.ttf + - asset: assets/fonts/Inter-Light.ttf + - asset: assets/fonts/Inter-Regular.ttf + - asset: assets/fonts/Inter-Medium.ttf + - asset: assets/fonts/Inter-SemiBold.ttf + - asset: assets/fonts/Inter-Bold.ttf + - asset: assets/fonts/Inter-ExtraBold.ttf + - asset: assets/fonts/Inter-Black.ttf + - asset: assets/fonts/Inter-ThinItalic.ttf + - asset: assets/fonts/Inter-ExtraLightItalic.ttf + - asset: assets/fonts/Inter-LightItalic.ttf + - asset: assets/fonts/Inter-Italic.ttf + - asset: assets/fonts/Inter-MediumItalic.ttf + - asset: assets/fonts/Inter-SemiBoldItalic.ttf + - asset: assets/fonts/Inter-BoldItalic.ttf + - asset: assets/fonts/Inter-ExtraBoldItalic.ttf + - asset: assets/fonts/Inter-BlackItalic.ttf + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + assets: + - assets/icons/logo.svg + - assets/fonts/ + - assets/release-notes.md + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package + +flutter_native_splash: + # This package generates native code to customize Flutter's default white native splash screen + # with background color and splash image. + # Customize the parameters below, and run the following command in the terminal: + # flutter pub run flutter_native_splash:create + # To restore Flutter's default white splash screen, run the following command in the terminal: + # flutter pub run flutter_native_splash:remove + + # color or background_image is the only required parameter. Use color to set the background + # of your splash screen to a solid color. Use background_image to set the background of your + # splash screen to a png image. This is useful for gradients. The image will be stretch to the + # size of the app. Only one parameter can be used, color and background_image cannot both be set. + # color: "#000000" + background_image: "assets/icons/bg.png" + + # Optional parameters are listed below. To enable a parameter, uncomment the line by removing + # the leading # character. + + # The image parameter allows you to specify an image used in the splash screen. It must be a + # png file and should be sized for 4x pixel density. + image: assets/icons/logo.png + + # This property allows you to specify an image used as branding in the splash screen. It must be + # a png file. Currently, it is only supported for Android and iOS. + # branding: assets/dart.png + + # Specify your branding image for dark mode. + #branding_dark: assets/dart_dark.png + + # To position the branding image at the bottom of the screen you can use bottom, bottomRight, + # and bottomLeft. The default values is bottom if not specified or specified something else. + # + # Make sure this content mode value should not be similar to android_gravity value and ios_content_mode + # value. + # branding_mode: bottom + + # The color_dark, background_image_dark, and image_dark are parameters that set the background + # and image when the device is in dark mode. If they are not specified, the app will use the + # parameters from above. If the image_dark parameter is specified, color_dark or + # background_image_dark must be specified. color_dark and background_image_dark cannot both be + # set. + #color_dark: "#042a49" + #background_image_dark: "assets/dark-background.png" + #image_dark: assets/splash-invert.png + + # The android, ios and web parameters can be used to disable generating a splash screen on a given + # platform. + #android: false + #ios: false + #web: false + + # The position of the splash image can be set with android_gravity, ios_content_mode, and + # web_image_mode parameters. All default to center. + # + # android_gravity can be one of the following Android Gravity (see + # https://developer.android.com/reference/android/view/Gravity): bottom, center, + # center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal, + # fill_vertical, left, right, start, or top. + #android_gravity: center + # + # ios_content_mode can be one of the following iOS UIView.ContentMode (see + # https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill, + # scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight, + # bottomLeft, or bottomRight. + #ios_content_mode: center + # + # web_image_mode can be one of the following modes: center, contain, stretch, and cover. + #web_image_mode: center + + # To hide the notification bar, use the fullscreen parameter. Has no affect in web since web + # has no notification bar. Defaults to false. + # NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads. + # To show the notification bar, add the following code to your Flutter app: + # WidgetsFlutterBinding.ensureInitialized(); + # SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]); + #fullscreen: true + + # If you have changed the name(s) of your info.plist file(s), you can specify the filename(s) + # with the info_plist_files parameter. Remove only the # characters in the three lines below, + # do not remove any spaces: + #info_plist_files: + # - 'ios/Runner/Info-Debug.plist' + # - 'ios/Runner/Info-Release.plist' + +flutter_launcher_icons: + image_path_android: "assets/icons/icon_android.png" + image_path_ios: "assets/icons/icon_ios.png" + android: true + ios: true + remove_alpha_ios: true From 29c4f74ef5b4f46520ecefd56542cfc283508ae3 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:33:24 -0700 Subject: [PATCH 5/9] ci: pin flutter to 3.44.1 in all workflows Workflows used subosito/flutter-action with no version, silently tracking GitHub's latest stable. When stable rolled to 3.44.1 it broke the build with no code change. Pin explicitly so builds are reproducible and Flutter upgrades are a deliberate bump rather than a surprise. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 1 + .github/workflows/gh-release.yml | 1 + .github/workflows/pr-check.yml | 1 + .github/workflows/store-release.yml | 2 ++ 4 files changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d49bfab7..3e77df25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ jobs: - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true - run: flutter doctor -v - run: flutter pub get diff --git a/.github/workflows/gh-release.yml b/.github/workflows/gh-release.yml index 1846001d..da793ce9 100644 --- a/.github/workflows/gh-release.yml +++ b/.github/workflows/gh-release.yml @@ -32,6 +32,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true # Decode and create the upload-keystore.jks file, used for signing the app. diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 5a454846..132c632e 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -10,6 +10,7 @@ jobs: - uses: actions/checkout@v6 - uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true - run: flutter doctor -v - run: flutter pub get diff --git a/.github/workflows/store-release.yml b/.github/workflows/store-release.yml index 75afb083..84b97315 100644 --- a/.github/workflows/store-release.yml +++ b/.github/workflows/store-release.yml @@ -49,6 +49,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true # Decode and create the upload-keystore.jks file, used for signing the app. @@ -100,6 +101,7 @@ jobs: - name: Set up Flutter uses: subosito/flutter-action@v2 with: + flutter-version: 3.44.1 cache: true # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). From f5718e36562abf30b472ac170a7e9667f77f8e40 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 22:42:37 -0700 Subject: [PATCH 6/9] fix(chat): restore channel assets when leaving shared chat When a shared chat session ended, the participants' merged emotes and badges stayed in the channel asset maps, so the other streamer's emote set and badges kept showing (#522). Only the enter transition was handled. Detect the leave transition (was shared, no longer shared) and reset: clear the loaded-participant tracking and re-run getAssets(), which replaces the channel emote/badge maps with this channel's own assets. Guarded on having actually loaded shared assets so a flapping source-room-id tag can't cause redundant refetches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../channel/chat/stores/chat_assets_store.dart | 12 ++++++++++++ lib/screens/channel/chat/stores/chat_store.dart | 10 ++++++++++ 2 files changed, 22 insertions(+) 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 ccc93d6c..4ed851ea 100644 --- a/lib/screens/channel/chat/stores/chat_store.dart +++ b/lib/screens/channel/chat/stores/chat_store.dart @@ -611,6 +611,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; From 17bbb20b94c70351e72b22c7513b0f3e8dba33e2 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 23:15:38 -0700 Subject: [PATCH 7/9] build(android): guard release signing to allow keyless compile checks Release builds now use the real upload key when key.properties is present, fall back to debug signing only when ALLOW_DEBUG_SIGNED_RELEASE is set (CI compile-only checks), and otherwise fail loudly. Lets CI compile a release APK without production secrets while ensuring a real release can never be silently debug-signed if the keystore step goes missing. Co-Authored-By: Claude Opus 4.8 (1M context) --- android/app/build.gradle.kts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c9f675d5..596b2812 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -50,7 +50,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") + } } } } From 7f4abdefeabd43a489b5a0a051b0111a0a1a55be Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 23:15:38 -0700 Subject: [PATCH 8/9] ci: add release dry-run workflow and release-mode compile checks Add a weekly + manual release-dryrun workflow that exercises the full store pipeline without publishing: Android validates the AAB against Google Play (validate_only), iOS builds a fully signed archive via match (readonly) and skips the App Store upload. Both Fastlane lanes gain a dry_run option that reuses the real release path so the dry-run stays faithful to it. Also add release-mode compile checks (flutter build apk/ios --release) to CI and PR checks to catch release-only breakage, pinning the JDK to match the release toolchain. Trim ci.yml from a redundant 3-OS matrix to two purpose-built jobs (analyze/test once on Linux, iOS build on macOS). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 34 ++++++-- .github/workflows/pr-check.yml | 13 +++ .github/workflows/release-dryrun.yml | 118 +++++++++++++++++++++++++++ android/fastlane/Fastfile | 7 +- ios/fastlane/Fastfile | 14 +++- 5 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release-dryrun.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e77df25..93c2d12a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,14 +3,20 @@ on: push: branches: [main] jobs: - test: - name: Check and test on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + # Linux runner: analyze + test once (both are OS-independent) and compile a + # release APK to catch release-only breakage. Debug-signed via the build.gradle + # fallback (ALLOW_DEBUG_SIGNED_RELEASE) — no signing secrets needed. + android: + name: Analyze, test, and build Android + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + # Match the JDK the release workflows build with, so this compile check + # faithfully catches what would break a real release. + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 - uses: subosito/flutter-action@v2 with: flutter-version: 3.44.1 @@ -19,3 +25,19 @@ jobs: - run: flutter pub get - run: flutter analyze - run: flutter test + - run: flutter build apk --release + env: + ALLOW_DEBUG_SIGNED_RELEASE: '1' + # macOS runner: release-compile iOS (no codesign, no secrets) — the only thing + # that must run on macOS. analyze/test already covered on the Linux job. + ios: + name: Build iOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.44.1 + cache: true + - run: flutter pub get + - run: flutter build ios --release --no-codesign diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 132c632e..2b632d17 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -8,6 +8,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + # Match the JDK the release workflows build with, so the release-mode APK + # check below faithfully catches what would break a real release. + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 - uses: subosito/flutter-action@v2 with: flutter-version: 3.44.1 @@ -16,3 +22,10 @@ jobs: - run: flutter pub get - run: flutter analyze - run: flutter test + # Release-mode compile check (no signing secrets needed — Android falls back + # to debug signing via ALLOW_DEBUG_SIGNED_RELEASE). Catches release-only + # breakage that debug builds and tests miss (AOT compile, icon tree-shaking, + # plugin/Gradle release incompatibility). + - run: flutter build apk --release + env: + ALLOW_DEBUG_SIGNED_RELEASE: '1' diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml new file mode 100644 index 00000000..7941f249 --- /dev/null +++ b/.github/workflows/release-dryrun.yml @@ -0,0 +1,118 @@ +# Dry-runs the full store release pipeline without publishing, to catch +# credential, signing, dependency, and store-validation breakage before an +# actual release. Mirrors store-release.yml but stops short of publishing: +# Android validates the AAB against Google Play (validate_only); iOS builds a +# fully signed archive via match (readonly) and skips the App Store upload. +name: Release dry-run +on: + # Run on demand before cutting a release. + workflow_dispatch: + # Weekly heartbeat so cert expiry, secret rotation, and dependency drift + # surface on their own between releases. + schedule: + - cron: '0 9 * * 1' # Mondays 09:00 UTC +# Setup environment variables for use in all jobs. +env: + # Gemfile path used for caching dependencies. + BUNDLE_GEMFILE: ${{ github.workspace }}/android/Gemfile + CLIENT_ID: ${{ secrets.CLIENT_ID }} + SECRET: ${{ secrets.SECRET }} + LC_ALL: en_US.UTF-8 + LANG: en_US.UTF-8 +jobs: + android: + name: Dry-run Android release + runs-on: ubuntu-latest + environment: release-android + env: + UPLOAD_KEYSTORE: ${{ secrets.UPLOAD_KEYSTORE }} + STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + KEY_FILE_PATH: android/key.properties + PLAY_STORE_CREDENTIALS: ${{ secrets.PLAY_STORE_CREDENTIALS }} + + steps: + - name: Checkout Frosty repo + uses: actions/checkout@v6 + + # Install Ruby and install/cache dependencies with bundler in order to use fastlane. + - name: Set up Ruby and Bundler + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2 + bundler-cache: true + + # Setup Java so that we can build the appbundle. + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + # Setup and cache Flutter to install packages and build. + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.44.1 + cache: true + + # Decode and create the upload-keystore.jks file, used for signing the app. + - name: Decode and create upload-keystore.jks + run: echo $UPLOAD_KEYSTORE | base64 --decode > $GITHUB_WORKSPACE/android/upload-keystore.jks + + # Create the key.properties file, used for signing the app. + - name: Create key.properties + run: | + echo storePassword=$STORE_PASSWORD >> $KEY_FILE_PATH + echo keyPassword=$KEY_PASSWORD >> $KEY_FILE_PATH + echo keyAlias=upload >> $KEY_FILE_PATH + echo storeFile=$GITHUB_WORKSPACE/android/upload-keystore.jks >> $KEY_FILE_PATH + + # Build the Android app bundle (AAB) and pass the environment variables. + - name: Build the Android AAB + run: flutter build appbundle --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET + + # Validate the AAB and credentials against Google Play without publishing. + - name: Validate release with fastlane (no publish) + run: cd android && bundle exec fastlane deploy dry_run:true + ios: + name: Dry-run iOS release + runs-on: macos-latest + environment: release-ios + env: + APP_STORE_KEY_ID: ${{ secrets.APP_STORE_KEY_ID }} + APP_STORE_ISSUER_ID: ${{ secrets.APP_STORE_ISSUER_ID }} + APP_STORE_CREDENTIALS: ${{ secrets.APP_STORE_CREDENTIALS }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + steps: + - name: Checkout Frosty repo + uses: actions/checkout@v6 + + # Always pick the newest production Xcode on the runner + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + # Install Ruby and install/cache dependencies with bundler in order to use fastlane. + - name: Set up Ruby and Bundler + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2 + bundler-cache: true + + # Setup and cache Flutter to install packages and build. + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: 3.44.1 + cache: true + + # Setup the iOS project with environment variables without signing (building and signing will happen in fastlane). + - name: Config the iOS build + run: flutter build ios --no-codesign --config-only --dart-define CLIENT_ID=$CLIENT_ID --dart-define SECRET=$SECRET + + # Authenticate ASC API key, resolve signing via match (readonly), and build a + # fully signed archive without uploading to the App Store. + - name: Sign and build with fastlane (no publish) + run: cd ios && bundle exec fastlane release dry_run:true diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 0390c22d..d2392565 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -17,11 +17,14 @@ default_platform(:android) platform :android do desc "Deploy a new version to the Google Play" - lane :deploy do + lane :deploy do |options| # Upload to the play store ussing the provided credentials and AAB from flutter build. + # With dry_run:true (release dry-run workflow), validate_only authenticates and + # validates the AAB against Google Play without committing the release. upload_to_play_store( json_key_data: Base64.decode64(ENV["PLAY_STORE_CREDENTIALS"]), - aab: "../build/app/outputs/bundle/release/app-release.aab" + aab: "../build/app/outputs/bundle/release/app-release.aab", + validate_only: options[:dry_run] || false ) end end diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 78bcb074..df241cb0 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -17,7 +17,9 @@ default_platform(:ios) platform :ios do desc 'Push a new release build to the App Store' - lane :release do + lane :release do |options| + dry_run = options[:dry_run] || false + # Setup for CI. Uses a keychain to prevent the CI from hanging due to password prompts. setup_ci @@ -29,8 +31,9 @@ platform :ios do is_key_content_base64: true ) - # Manage code signing (with match). - sync_code_signing + # Manage code signing (with match). readonly on a dry run so we only verify the + # existing cert/profile resolve and never regenerate or mutate the certificates repo. + sync_code_signing(readonly: dry_run) # Automatic signing is used during development, but fastlane needs to use manual signing. update_code_signing_settings( @@ -45,6 +48,11 @@ platform :ios do output_directory: '../build/ios/archive' ) + # On a dry run, stop here: the signed archive above exercises every step that + # actually fails (ASC key, match certs/profiles, signing, release build). The + # App Store upload itself is the only remaining unverified step. + next if dry_run + # Upload the app to the App Store. upload_to_app_store( submit_for_review: true, From 8883af90f05dbf4a284092594c08f0154a53e083 Mon Sep 17 00:00:00 2001 From: Tommy Chow Date: Mon, 8 Jun 2026 23:39:51 -0700 Subject: [PATCH 9/9] fix(ios): resolve Crashlytics upload-symbols for SPM builds The Crashlytics 'Upload Symbols' build phase was hardcoded to the CocoaPods path ($PODS_ROOT/FirebaseCrashlytics/upload-symbols), but Flutter 3.44 + firebase_crashlytics 5.x resolve Firebase via Swift Package Manager, so that path no longer exists and every release build (including fastlane build_app) failed with 'No such file or directory'. Fall back to the SPM checkout path when the pod isn't present so the upload works under either integration. Co-Authored-By: Claude Opus 4.8 (1M context) --- ios/Runner.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3c0a4184..e82e9a80 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -246,7 +246,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" "; + shellScript = "# Locate upload-symbols whether Firebase is integrated via CocoaPods or Swift Package Manager.\nBIN=\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\"\n[ -f \"$BIN\" ] || BIN=\"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols\"\n\"$BIN\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\"\n"; }; 34CC65D58A1948B306772884 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase;