From 7a2c498019a98ea6704b3e21888093865c64ce25 Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 2 Sep 2026 04:34:23 +0500 Subject: [PATCH 1/5] MOBILE-341: Let the block's drag play by the device's rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The horizontal drag recognizer handed to the platform view is built by hand, so nothing gave it the device's touch slop the way the framework gives it to every scrollable. Left to itself it fell back to kTouchSlop — 18 logical pixels against the 8 an Android scrollable plays with — and a parent that wants the same direction crossed its threshold first: the arena closed before the block was anywhere near its own, and the native carousel was never told a finger had been on it. A PageView around the block turned the page while the feed stood still; a vertical list never showed it, because it measures the other axis. The recognizer now takes DeviceGestureSettings from MediaQuery, so the block plays by the same rules as everyone else on the screen and wins the drag as the one closest to the finger. Nothing is exposed to hosts — there was no handle for this to begin with. Checked by hand on both platforms, on the PageView tab of the demo's scroll scenarios. Android reproduces the bug without the change and is correct with it, while a drag beside the block still turns the page. iOS behaves the same either way: it reports no device touch slop, so both claimants already sat at kTouchSlop and the block already won on being nearest — the change is a no-op there, kept for the platform that needs it. --- mindbox/lib/src/embedded_block.dart | 11 +- mindbox/test/embedded_block_test.dart | 161 ++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 2943ea5..47a96ef 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -275,11 +275,20 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { creationParams[EmbeddedBlockParams.timeoutMs] = timeout.inMilliseconds; } + // The recognizer is built by hand rather than by a RawGestureDetector, so nothing hands it the + // touch slop of the device the way the framework hands it to every scrollable. Left to itself it + // falls back to kTouchSlop — 18 logical pixels against the 8 an Android scrollable plays with — + // and a scrollable that wants the same direction takes the drag while the block is still short + // of its own threshold: the arena closes, and the carousel is never told a finger was on it. On + // equal slop the drag goes to whoever is closest to the finger, and inside the block that is the + // block. + final DeviceGestureSettings? gestureSettings = MediaQuery.maybeGestureSettingsOf(context); + final Set> gestureRecognizers = _appearance == EmbeddedBlockAppearance.content ? >{ Factory( - () => HorizontalDragGestureRecognizer(), + () => HorizontalDragGestureRecognizer()..gestureSettings = gestureSettings, ), } : const >{}; diff --git a/mindbox/test/embedded_block_test.dart b/mindbox/test/embedded_block_test.dart index 698b353..0230aba 100644 --- a/mindbox/test/embedded_block_test.dart +++ b/mindbox/test/embedded_block_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -482,4 +483,164 @@ void main() { expect(methods, isNot(contains(EmbeddedBlockMethods.release))); }); }); + + group('A drag that two scrollables want', () { + // The device slop an Android scrollable plays with. The block used to fall back to kTouchSlop — + // 18 — and lose every horizontal drag to a parent that crosses 8 first. + const DeviceGestureSettings settings = DeviceGestureSettings(touchSlop: 8); + + late int viewId; + + setUp(() { + viewId = -1; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, (MethodCall call) async { + // 'touch' carries a list, not a map: the block wins the arena and forwards the drag, so + // this handler is asked about more than the two methods it answers. + if (call.method != 'create' && call.method != 'resize') { + return null; + } + + final Map arguments = call.arguments as Map; + + if (call.method == 'resize') { + return { + 'width': arguments['width'], + 'height': arguments['height'], + }; + } + + viewId = arguments['id']! as int; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(embeddedBlockChannelName(viewId)), + (MethodCall call) async => null, + ); + return 0; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform_views, null); + }); + + /// The native block saying it has content on screen — the only state in which the block asks + /// for horizontal drags at all. + Future showContent(WidgetTester tester) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + embeddedBlockChannelName(viewId), + const StandardMethodCodec().encodeMethodCall( + const MethodCall( + EmbeddedBlockMethods.report, + {'appearance': 'content'}, + ), + ), + (ByteData? _) {}, + ); + await tester.pumpAndSettle(); + } + + Future showBlockInPageView(WidgetTester tester) async { + final PageController pages = PageController(); + addTearDown(pages.dispose); + + await tester.pumpWidget(MediaQuery( + data: const MediaQueryData(gestureSettings: settings), + child: Directionality( + textDirection: TextDirection.ltr, + child: PageView( + controller: pages, + children: const [ + Column( + children: [ + SizedBox(height: 200), + MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104), + ], + ), + SizedBox.expand(), + ], + ), + ), + )); + await tester.pumpAndSettle(); + await showContent(tester); + + return pages; + } + + /// A finger crossing the screen the way a finger does — in small steps, not in one jump. The + /// step matters: whoever reaches its own slop on an earlier step closes the arena, and a block + /// that waits for 18 never gets to answer a parent that is done at 8. + Future dragBy(WidgetTester tester, Offset start, double distance) async { + final TestGesture gesture = await tester.startGesture(start); + for (double moved = 0; moved < distance.abs(); moved += 4) { + await gesture.moveBy(Offset(4 * distance.sign, 0)); + await tester.pump(); + } + await gesture.up(); + await tester.pumpAndSettle(); + } + + testWidgets('A drag on the block is the block\'s, and the page stays where it is', + (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final PageController pages = await showBlockInPageView(tester); + + await dragBy(tester, tester.getCenter(find.byType(AndroidView)), -600); + + expect(pages.page, 0); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('A drag beside the block still turns the page', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final PageController pages = await showBlockInPageView(tester); + + final Offset besideTheBlock = tester.getCenter(find.byType(AndroidView)) - const Offset(0, 150); + await dragBy(tester, besideTheBlock, -600); + + expect(pages.page, 1); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('A block still loading leaves the drag to the page', (WidgetTester tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + final PageController pages = PageController(); + addTearDown(pages.dispose); + + await tester.pumpWidget(MediaQuery( + data: const MediaQueryData(gestureSettings: settings), + child: Directionality( + textDirection: TextDirection.ltr, + child: PageView( + controller: pages, + children: const [ + Column( + children: [ + SizedBox(height: 200), + MindboxEmbeddedBlock(placeSystemName: 'stories', height: 104), + ], + ), + SizedBox.expand(), + ], + ), + ), + )); + await tester.pumpAndSettle(); + + await dragBy(tester, tester.getCenter(find.byType(AndroidView)), -600); + + expect(pages.page, 1); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + }); } From d17b8543cfc8fe06d57a4e91fab7268a462d3d29 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 14 Sep 2026 14:17:32 +0500 Subject: [PATCH 2/5] MOBILE-341: Move the native SDKs to 2.16.0-rc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The released 2.16.0-rc carries the embedded block hooks the widget leans on, so the iOS manifest goes back to the published tag instead of the working copy it had to point at while those hooks lived only on a branch. The podspec follows it, and the Android side moves to the same release. mindbox-common is named explicitly because @InternalMindboxApi lives there and mobile-sdk does not re-export it: without it the plugin's Kotlin does not compile. It is compileOnly — the annotation is needed to build against the SDK, not to ship alongside it. --- mindbox_android/android/build.gradle | 3 ++- mindbox_ios/ios/mindbox_ios.podspec | 4 ++-- mindbox_ios/ios/mindbox_ios/Package.swift | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mindbox_android/android/build.gradle b/mindbox_android/android/build.gradle index 6a56dab..438d53b 100644 --- a/mindbox_android/android/build.gradle +++ b/mindbox_android/android/build.gradle @@ -50,5 +50,6 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - api 'cloud.mindbox:mobile-sdk:2.15.2' + api 'cloud.mindbox:mobile-sdk:2.16.0-rc' + compileOnly 'cloud.mindbox:mindbox-common:2.16.0-rc' } diff --git a/mindbox_ios/ios/mindbox_ios.podspec b/mindbox_ios/ios/mindbox_ios.podspec index 93f5e29..980547e 100644 --- a/mindbox_ios/ios/mindbox_ios.podspec +++ b/mindbox_ios/ios/mindbox_ios.podspec @@ -15,8 +15,8 @@ The implementation of 'mindbox' plugin for the iOS platform s.source = { :path => '.' } s.source_files = 'mindbox_ios/Sources/mindbox_ios/**/*.swift', 'Classes/MindboxFlutterAppDelegate.{h,m}' s.dependency 'Flutter' - s.dependency 'Mindbox', '2.15.1' - s.dependency 'MindboxNotifications', '2.15.1' + s.dependency 'Mindbox', '2.16.0-rc' + s.dependency 'MindboxNotifications', '2.16.0-rc' s.platform = :ios, '12.0' # Flutter.framework does not contain a i386 slice. diff --git a/mindbox_ios/ios/mindbox_ios/Package.swift b/mindbox_ios/ios/mindbox_ios/Package.swift index 177b7ef..ab1077d 100644 --- a/mindbox_ios/ios/mindbox_ios/Package.swift +++ b/mindbox_ios/ios/mindbox_ios/Package.swift @@ -10,7 +10,7 @@ let package = Package( .library(name: "mindbox-ios", targets: ["mindbox_ios"]) ], dependencies: [ - .package(url: "https://github.com/mindbox-cloud/ios-sdk", exact: "2.15.1"), + .package(url: "https://github.com/mindbox-cloud/ios-sdk", exact: "2.16.0-rc"), ], targets: [ .target( From e527e576fff0c50a5716431de705986d96f142d0 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 14 Sep 2026 15:23:14 +0500 Subject: [PATCH 3/5] MOBILE-341: Say which Flutter the block's drag needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packages claimed Flutter 2.0 while the recognizer reached for MediaQuery's aspect accessor, which only exists from 3.10: pub would install the plugin on an older SDK and the host would then fail to compile it. The settings themselves have been on MediaQueryData since 2.8, so reading the data whole asks for far less and returns the same value. The floor moves to 3.0 across the four packages — the first Flutter 3, with room to spare over what the code actually needs. A platform view keeps the recognizer it was first handed, so watching all of MediaQuery instead of one aspect costs nothing here: either way the settings are read once. --- mindbox/lib/src/embedded_block.dart | 6 +++++- mindbox/pubspec.yaml | 2 +- mindbox_android/pubspec.yaml | 2 +- mindbox_ios/pubspec.yaml | 2 +- mindbox_platform_interface/pubspec.yaml | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/mindbox/lib/src/embedded_block.dart b/mindbox/lib/src/embedded_block.dart index 47a96ef..f84c7dd 100644 --- a/mindbox/lib/src/embedded_block.dart +++ b/mindbox/lib/src/embedded_block.dart @@ -282,7 +282,11 @@ class _EmbeddedBlockState extends State<_EmbeddedBlock> { // of its own threshold: the arena closes, and the carousel is never told a finger was on it. On // equal slop the drag goes to whoever is closest to the finger, and inside the block that is the // block. - final DeviceGestureSettings? gestureSettings = MediaQuery.maybeGestureSettingsOf(context); + // + // Read whole rather than by aspect: the aspect accessors arrived in Flutter 3.10, and the plugin + // still speaks to 3.0. Watching all of MediaQuery costs nothing here — a platform view keeps the + // recognizer it was first given, so the settings are read once however they are asked for. + final DeviceGestureSettings? gestureSettings = MediaQuery.maybeOf(context)?.gestureSettings; final Set> gestureRecognizers = _appearance == EmbeddedBlockAppearance.content diff --git a/mindbox/pubspec.yaml b/mindbox/pubspec.yaml index cb5ac16..8a14f44 100644 --- a/mindbox/pubspec.yaml +++ b/mindbox/pubspec.yaml @@ -7,7 +7,7 @@ documentation: https://developers.mindbox.ru/docs/flutter-sdk-integration environment: sdk: ">=2.12.0 <4.0.0" - flutter: ">=2.0.0" + flutter: ">=3.0.0" flutter: plugin: diff --git a/mindbox_android/pubspec.yaml b/mindbox_android/pubspec.yaml index 6283506..02d6642 100644 --- a/mindbox_android/pubspec.yaml +++ b/mindbox_android/pubspec.yaml @@ -6,7 +6,7 @@ repository: https://github.com/mindbox-cloud/flutter-sdk/tree/master/mindbox_and environment: sdk: ">=2.12.0 <4.0.0" - flutter: ">=2.0.0" + flutter: ">=3.0.0" flutter: plugin: diff --git a/mindbox_ios/pubspec.yaml b/mindbox_ios/pubspec.yaml index 2a6541e..f7fa8d8 100644 --- a/mindbox_ios/pubspec.yaml +++ b/mindbox_ios/pubspec.yaml @@ -6,7 +6,7 @@ repository: https://github.com/mindbox-cloud/flutter-sdk/tree/master/mindbox_ios environment: sdk: ">=2.12.0 <4.0.0" - flutter: ">=2.0.0" + flutter: ">=3.0.0" flutter: plugin: diff --git a/mindbox_platform_interface/pubspec.yaml b/mindbox_platform_interface/pubspec.yaml index 5e6d711..5458663 100644 --- a/mindbox_platform_interface/pubspec.yaml +++ b/mindbox_platform_interface/pubspec.yaml @@ -6,7 +6,7 @@ repository: https://github.com/mindbox-cloud/flutter-sdk/tree/master/mindbox_pla environment: sdk: ">=2.12.0 <4.0.0" - flutter: ">=2.0.0" + flutter: ">=3.0.0" dependencies: flutter: From 8f3dd8320ec017cbce029fd33b3d0289f07303b9 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 14 Sep 2026 18:49:03 +0500 Subject: [PATCH 4/5] MOBILE-341: Carry every Mindbox pin through the release bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the CI job and the script run by hand rewrote one line by name — the mobile-sdk dependency — so the mindbox-common pin added beside it would have kept the previous version while everything around it moved, and nothing checked: the Gradle file had no assertion at all, and branch protection only reads the root pubspec. The substitution now covers any cloud.mindbox artifact in the file, the two named today are asserted the way the iOS pins already are, and a sweep afterwards stops the release on anything still holding another version — a dependency nobody thought to name here, or one written in quotes the pattern does not reach. The same hole was closed in react-native-sdk in #222; this is its counterpart, and it covers both places a release can be cut from. --- .../manual-prepare_release_branch.yml | 23 +++++++++++++++---- git-release-branch.sh | 21 +++++++++++++---- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.github/workflows/manual-prepare_release_branch.yml b/.github/workflows/manual-prepare_release_branch.yml index 20c6258..b963ae2 100644 --- a/.github/workflows/manual-prepare_release_branch.yml +++ b/.github/workflows/manual-prepare_release_branch.yml @@ -164,14 +164,27 @@ jobs: done - echo "→ Bumping Android native SDK in build.gradle" - echo " Before:" && grep "cloud.mindbox:mobile-sdk" mindbox_android/android/build.gradle || true - sed -i "s/cloud.mindbox:mobile-sdk:.*/cloud.mindbox:mobile-sdk:$AND_VER'/" mindbox_android/android/build.gradle - echo " After:" && grep "cloud.mindbox:mobile-sdk" mindbox_android/android/build.gradle || true - # Fail the release if a version substitution didn't land (stale pin). assert_pin() { grep -qE "$2" "$1" || { echo "ERROR: pattern /$2/ not found in $1 — substitution failed"; exit 1; }; } + echo "→ Bumping Android native SDK in build.gradle" + echo " Before:" && grep "cloud.mindbox:" mindbox_android/android/build.gradle || true + # Every cloud.mindbox artifact in this file ships from the same native release, so they all + # take the same version — the ones named today and any added after this was written. + sed -i -E "s/(cloud\.mindbox:[a-z0-9-]+:)[^']*'/\1$AND_VER'/" mindbox_android/android/build.gradle + echo " After:" && grep "cloud.mindbox:" mindbox_android/android/build.gradle || true + + # Named artifacts are asserted the way the iOS pins are; the sweep after them catches a + # dependency nobody thought to name here, which would otherwise ship stale without a word. + assert_pin mindbox_android/android/build.gradle "cloud\.mindbox:mobile-sdk:$AND_VER'" + assert_pin mindbox_android/android/build.gradle "cloud\.mindbox:mindbox-common:$AND_VER'" + STALE=$(grep -nE "cloud\.mindbox:[a-z0-9-]+:" mindbox_android/android/build.gradle | grep -v ":$AND_VER'" || true) + if [ -n "$STALE" ]; then + echo "ERROR: build.gradle still pins Mindbox artifacts to another version:" + echo "$STALE" + exit 1 + fi + echo "→ Bumping iOS native SDK in podspec" echo " Before s.version:" && grep -E "s\.version" mindbox_ios/ios/mindbox_ios.podspec || true sed -i -E "s/(s\.version *= *')[^']+(')/\1$IO_VER\2/" mindbox_ios/ios/mindbox_ios.podspec diff --git a/git-release-branch.sh b/git-release-branch.sh index 98aaf59..3b8de2b 100755 --- a/git-release-branch.sh +++ b/git-release-branch.sh @@ -68,17 +68,28 @@ if ! [[ $ios_sdk_version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc)?$ ]]; then exit 1 fi -android_gradle="mindbox_android/android/build.gradle" -sed -i '' "s/ api 'cloud.mindbox:mobile-sdk:.*/ api 'cloud.mindbox:mobile-sdk:$android_sdk_version\'/" $android_gradle - -echo "Bump $android_gradle to $android_sdk_version" - # Fail loudly if a version substitution didn't land (e.g. the line format # changed and sed silently matched nothing, leaving a stale pin). assert_pin() { # grep -qE "$2" "$1" || { echo "ERROR: pattern /$2/ not found in $1 — version substitution failed"; exit 1; } } +android_gradle="mindbox_android/android/build.gradle" +# Every cloud.mindbox artifact in this file ships from the same native release, so they all +# take the same version — the ones named today and any added after this was written. +sed -i '' -E "s/(cloud\.mindbox:[a-z0-9-]+:)[^']*'/\1$android_sdk_version'/" $android_gradle + +echo "Bump $android_gradle to $android_sdk_version" + +assert_pin $android_gradle "cloud\.mindbox:mobile-sdk:$android_sdk_version'" +assert_pin $android_gradle "cloud\.mindbox:mindbox-common:$android_sdk_version'" +stale_pins=$(grep -nE "cloud\.mindbox:[a-z0-9-]+:" $android_gradle | grep -v ":$android_sdk_version'" || true) +if [ -n "$stale_pins" ]; then + echo "ERROR: $android_gradle still pins Mindbox artifacts to another version:" + echo "$stale_pins" + exit 1 +fi + ios_podspec="mindbox_ios/ios/mindbox_ios.podspec" sed -i '' "s/ s.version = .*/ s.version = '$ios_sdk_version'/" $ios_podspec From 201e6a870f4c1d18135f3cd23bedc92e5fb22fac Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 14 Sep 2026 18:49:03 +0500 Subject: [PATCH 5/5] MOBILE-341: Say which Dart the packages need The Dart floor was still the one set when the packages went null-safe, and it no longer says anything: Flutter 3.0 carries Dart 2.17, so nothing below that can reach these packages anyway. Naming 2.17 costs nothing and leaves one fewer constraint that means less than it appears to. Nothing in the four packages asks for a later language: no records, no patterns, no class modifiers, not even super parameters. --- mindbox/pubspec.yaml | 2 +- mindbox_android/pubspec.yaml | 2 +- mindbox_ios/pubspec.yaml | 2 +- mindbox_platform_interface/pubspec.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mindbox/pubspec.yaml b/mindbox/pubspec.yaml index 8a14f44..d09755e 100644 --- a/mindbox/pubspec.yaml +++ b/mindbox/pubspec.yaml @@ -6,7 +6,7 @@ repository: https://github.com/mindbox-cloud/flutter-sdk/tree/master/mindbox documentation: https://developers.mindbox.ru/docs/flutter-sdk-integration environment: - sdk: ">=2.12.0 <4.0.0" + sdk: ">=2.17.0 <4.0.0" flutter: ">=3.0.0" flutter: diff --git a/mindbox_android/pubspec.yaml b/mindbox_android/pubspec.yaml index 02d6642..fa6428e 100644 --- a/mindbox_android/pubspec.yaml +++ b/mindbox_android/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://mindbox.cloud/ repository: https://github.com/mindbox-cloud/flutter-sdk/tree/master/mindbox_android environment: - sdk: ">=2.12.0 <4.0.0" + sdk: ">=2.17.0 <4.0.0" flutter: ">=3.0.0" flutter: diff --git a/mindbox_ios/pubspec.yaml b/mindbox_ios/pubspec.yaml index f7fa8d8..8b9fbad 100644 --- a/mindbox_ios/pubspec.yaml +++ b/mindbox_ios/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://mindbox.cloud/ repository: https://github.com/mindbox-cloud/flutter-sdk/tree/master/mindbox_ios environment: - sdk: ">=2.12.0 <4.0.0" + sdk: ">=2.17.0 <4.0.0" flutter: ">=3.0.0" flutter: diff --git a/mindbox_platform_interface/pubspec.yaml b/mindbox_platform_interface/pubspec.yaml index 5458663..448db44 100644 --- a/mindbox_platform_interface/pubspec.yaml +++ b/mindbox_platform_interface/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://mindbox.cloud/ repository: https://github.com/mindbox-cloud/flutter-sdk/tree/master/mindbox_platform_interface environment: - sdk: ">=2.12.0 <4.0.0" + sdk: ">=2.17.0 <4.0.0" flutter: ">=3.0.0" dependencies: