diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..ccc4c6c7f8 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.20.2 diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 3b3a366784..0000000000 --- a/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/android/app/src/main/java/com/shoutemapp/MainApplication.kt b/android/app/src/main/java/com/shoutemapp/MainApplication.kt index 4f38f7316e..45151eb8d3 100644 --- a/android/app/src/main/java/com/shoutemapp/MainApplication.kt +++ b/android/app/src/main/java/com/shoutemapp/MainApplication.kt @@ -5,13 +5,11 @@ import androidx.multidex.MultiDexApplication import com.facebook.react.PackageList import com.facebook.react.ReactApplication import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative import com.facebook.react.ReactNativeHost import com.facebook.react.ReactPackage -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost import com.facebook.react.defaults.DefaultReactNativeHost -import com.facebook.react.soloader.OpenSourceMergedSoMapping -import com.facebook.soloader.SoLoader //NativeModuleInjectionMark-mainApplication-import @@ -46,11 +44,7 @@ class MainApplication : MultiDexApplication(), ReactApplication { override fun onCreate() { super.onCreate() - SoLoader.init(this, OpenSourceMergedSoMapping) - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // If you opted-in for the New Architecture, we load the native entry point for this app. - load() - } + loadReactNative(this) //NativeModuleInjectionMark-mainApplication-oncreate-end } } diff --git a/android/build.gradle b/android/build.gradle index 3adca08a62..614f6662ce 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -9,7 +9,7 @@ buildscript { dependencies { classpath("com.android.tools.build:gradle") classpath("com.facebook.react:react-native-gradle-plugin") - classpath('org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21') + classpath('org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20') classpath("de.undercouch:gradle-download-task:4.1.2") //NativeModuleInjectionMark-root-gradle diff --git a/android/constants.gradle b/android/constants.gradle index 659e110c8f..5d7e9279f4 100644 --- a/android/constants.gradle +++ b/android/constants.gradle @@ -1,10 +1,10 @@ project.ext { - buildToolsVersion = "35.0.0" + buildToolsVersion = "36.0.0" minSdkVersion = 24 - compileSdkVersion = 35 - targetSdkVersion = 35 + compileSdkVersion = 36 + targetSdkVersion = 36 ndkVersion = "27.1.12297006" - kotlinVersion = "2.0.21" + kotlinVersion = "2.1.20" supportLibVersion = '28.0.0' googlePlayServicesVersion = '15.0.0' diff --git a/android/gradle.properties b/android/gradle.properties index 39a1878a03..15e3f836b0 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -38,6 +38,11 @@ newArchEnabled=false # If set to false, you will be using JSC instead. hermesEnabled=true +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=false + #NativeModuleInjectionMark-gradle-properties #keystore settings diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index a4b76b9530..1b33c55baa 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e0fd02028b..d4081da476 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/android/gradlew b/android/gradlew index f3b75f3b0d..23d15a9367 100755 --- a/android/gradlew +++ b/android/gradlew @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -205,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/android/gradlew.bat b/android/gradlew.bat index 9d21a21834..dd2b8eedbd 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,3 +1,8 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + @rem @rem Copyright 2015 the original author or authors. @rem @@ -70,11 +75,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/core/AppBuilder.js b/core/AppBuilder.js index 53b896789a..364c01df6d 100644 --- a/core/AppBuilder.js +++ b/core/AppBuilder.js @@ -1,6 +1,5 @@ /* eslint-disable max-classes-per-file */ import React, { Component, createContext } from 'react'; -import { YellowBox } from 'react-native'; import { assertExtensionsExist, assertNotEmpty, @@ -15,15 +14,6 @@ const APP_CONTEXT = Symbol('appContext'); export let AppContextProvider; -// Temporarily ignoring cyclic dependency warnings and deprecated lifecycle -// methods until package maintainers can resolve these -YellowBox.ignoreWarnings([ - 'Require cycle:', - 'componentWillReceiveProps', - 'componentWillUpdate', - 'componentWillMount', -]); - /** * Creates an application class that represents a root * react native component, and uses the context initialized diff --git a/ios/ShoutemApp.xcodeproj/project.pbxproj b/ios/ShoutemApp.xcodeproj/project.pbxproj index 29a2cd97c0..5cfce9f59c 100644 --- a/ios/ShoutemApp.xcodeproj/project.pbxproj +++ b/ios/ShoutemApp.xcodeproj/project.pbxproj @@ -364,7 +364,7 @@ "$(inherited)", /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, ); - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; OTHER_CPLUSPLUSFLAGS = ( @@ -423,7 +423,7 @@ "$(inherited)", /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, ); - IPHONEOS_DEPLOYMENT_TARGET = 13.4; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = NO; OTHER_CPLUSPLUSFLAGS = ( diff --git a/ios/ShoutemApp/AppDelegate.swift b/ios/ShoutemApp/AppDelegate.swift index 7366385863..b6b60df294 100644 --- a/ios/ShoutemApp/AppDelegate.swift +++ b/ios/ShoutemApp/AppDelegate.swift @@ -12,27 +12,48 @@ import React import React_RCTAppDelegate import ReactAppDependencyProvider //NativeModuleInjectionMark-appDelegate-import - + @main -class AppDelegate: RCTAppDelegate { - override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - self.moduleName = "ShoutemApp" - self.dependencyProvider = RCTAppDependencyProvider() - - // You can add your custom initial props in the dictionary below. - // They will be passed down to the ViewController used by React Native. - self.initialProps = [:] - +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + var reactNativeDelegate: ReactNativeDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = RCTReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + + window = UIWindow(frame: UIScreen.main.bounds) + //NativeModuleInjectionMark-appDelegate-applicationDidFinishLaunchingWithOptions - let didFinish = super.application(application, didFinishLaunchingWithOptions: launchOptions) + + factory.startReactNative( + withModuleName: "ShoutemApp", + in: window, + launchOptions: launchOptions + ) + //NativeModuleInjectionMark-appDelegate-applicationDidFinishLaunchingWithOptions-end - return didFinish + + return true } - + + //NativeModuleInjectionMark-appDelegate-body +} + +class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { override func sourceURL(for bridge: RCTBridge) -> URL? { self.bundleURL() } - + override func bundleURL() -> URL? { #if DEBUG return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") @@ -40,6 +61,4 @@ class AppDelegate: RCTAppDelegate { return Bundle.main.url(forResource: "main", withExtension: "jsbundle") #endif } - - //NativeModuleInjectionMark-appDelegate-body } diff --git a/metro.config.js b/metro.config.js index 1411248a25..a0732ae493 100644 --- a/metro.config.js +++ b/metro.config.js @@ -6,7 +6,11 @@ */ const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); -const exclusionList = require('metro-config/src/defaults/exclusionList'); +// Metro 0.83 (RN 0.81) gates `metro-config` behind an `exports` map, so `src/` is no +// longer reachable - `private/*` is the supported subpath. The module is Babel-compiled, +// hence `.default`. +const exclusionList = + require('metro-config/private/defaults/exclusionList').default; const path = require('path'); // parameters adjusted by CI scripts diff --git a/package.json b/package.json index 5a8bcff31b..a03228a6d2 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,21 @@ { "name": "@shoutem/platform", - "version": "17.0.11", + "version": "18.0.0-rc.2", "scripts": { "android": "react-native run-android", "build": "node scripts/build", "bundle": "node scripts/bundle", "configure": "node scripts/configure", "ios": "react-native run-ios", - "lint": "eslint .", + "lint": "node scripts/lint.js", "lint-fix": "eslint . --fix", "list-dependencies": "node scripts/platform-dependencies", "postinstall": "node scripts/postinstall", "setup": "cd scripts && yarn install", "start": "react-native start", "build-web": "NODE_ENV=production node web/esbuild.config.mjs", - "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs" + "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs", + "lint-extensions": "eslint extensions/*/app --max-warnings 0" }, "dependencies": { "auto-bind": "4.0.0", @@ -22,8 +23,8 @@ "lodash": "4.17.21", "moment": "2.29.4", "prop-types": "15.7.1", - "react": "19.0.0", - "react-native": "0.78.0", + "react": "19.1.4", + "react-native": "0.81.6", "react-redux": "7.2.6", "redux": "3.6.0", "redux-action-buffer": "1.0.1", @@ -39,11 +40,11 @@ "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", "@babel/plugin-proposal-decorators": "7.20.13", - "@react-native/babel-preset": "0.78.0", - "@react-native/eslint-config": "0.78.0", - "@react-native/metro-config": "0.78.0", + "@react-native/babel-preset": "0.81.6", + "@react-native/eslint-config": "0.81.6", + "@react-native/metro-config": "0.81.6", "@shoutem/build-tools": "file:scripts/helpers", - "@shoutem/eslint-config-react-native": "~1.0.2", + "@shoutem/eslint-config-react-native": "~1.1.0-rc.0", "@welldone-software/why-did-you-render": "10.0.1", "babel-plugin-jest-hoist": "24.9.0", "eslint-plugin-import": "2.25.4", @@ -58,10 +59,10 @@ "prettier": "2.8.8", "patch-package": "6.4.7", "react-native-asset": "2.1.1", - "react-test-renderer": "19.0.0", - "@react-native-community/cli": "15.0.1", - "@react-native-community/cli-platform-android": "15.0.1", - "@react-native-community/cli-platform-ios": "15.0.1" + "react-test-renderer": "19.1.0", + "@react-native-community/cli": "20.0.0", + "@react-native-community/cli-platform-android": "20.0.0", + "@react-native-community/cli-platform-ios": "20.0.0" }, "eslintConfig": { "extends": "@shoutem/react-native", @@ -102,6 +103,6 @@ "preset": "module:react-native" }, "engines": { - "node": ">=18" + "node": ">=20" } } diff --git a/package.template.json b/package.template.json index 5a8bcff31b..a03228a6d2 100644 --- a/package.template.json +++ b/package.template.json @@ -1,20 +1,21 @@ { "name": "@shoutem/platform", - "version": "17.0.11", + "version": "18.0.0-rc.2", "scripts": { "android": "react-native run-android", "build": "node scripts/build", "bundle": "node scripts/bundle", "configure": "node scripts/configure", "ios": "react-native run-ios", - "lint": "eslint .", + "lint": "node scripts/lint.js", "lint-fix": "eslint . --fix", "list-dependencies": "node scripts/platform-dependencies", "postinstall": "node scripts/postinstall", "setup": "cd scripts && yarn install", "start": "react-native start", "build-web": "NODE_ENV=production node web/esbuild.config.mjs", - "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs" + "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs", + "lint-extensions": "eslint extensions/*/app --max-warnings 0" }, "dependencies": { "auto-bind": "4.0.0", @@ -22,8 +23,8 @@ "lodash": "4.17.21", "moment": "2.29.4", "prop-types": "15.7.1", - "react": "19.0.0", - "react-native": "0.78.0", + "react": "19.1.4", + "react-native": "0.81.6", "react-redux": "7.2.6", "redux": "3.6.0", "redux-action-buffer": "1.0.1", @@ -39,11 +40,11 @@ "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", "@babel/plugin-proposal-decorators": "7.20.13", - "@react-native/babel-preset": "0.78.0", - "@react-native/eslint-config": "0.78.0", - "@react-native/metro-config": "0.78.0", + "@react-native/babel-preset": "0.81.6", + "@react-native/eslint-config": "0.81.6", + "@react-native/metro-config": "0.81.6", "@shoutem/build-tools": "file:scripts/helpers", - "@shoutem/eslint-config-react-native": "~1.0.2", + "@shoutem/eslint-config-react-native": "~1.1.0-rc.0", "@welldone-software/why-did-you-render": "10.0.1", "babel-plugin-jest-hoist": "24.9.0", "eslint-plugin-import": "2.25.4", @@ -58,10 +59,10 @@ "prettier": "2.8.8", "patch-package": "6.4.7", "react-native-asset": "2.1.1", - "react-test-renderer": "19.0.0", - "@react-native-community/cli": "15.0.1", - "@react-native-community/cli-platform-android": "15.0.1", - "@react-native-community/cli-platform-ios": "15.0.1" + "react-test-renderer": "19.1.0", + "@react-native-community/cli": "20.0.0", + "@react-native-community/cli-platform-android": "20.0.0", + "@react-native-community/cli-platform-ios": "20.0.0" }, "eslintConfig": { "extends": "@shoutem/react-native", @@ -102,6 +103,6 @@ "preset": "module:react-native" }, "engines": { - "node": ">=18" + "node": ">=20" } } diff --git a/package.template.web.json b/package.template.web.json index 5b1bfc8cfc..3cfa63c8fe 100644 --- a/package.template.web.json +++ b/package.template.web.json @@ -1,17 +1,18 @@ { "name": "@shoutem/platform", - "version": "17.0.11", + "version": "18.0.0-rc.2", "scripts": { "build": "node scripts/build", "bundle": "node scripts/bundle", "configure": "node scripts/configure", - "lint": "eslint .", + "lint": "node scripts/lint.js", "lint-fix": "eslint . --fix", "list-dependencies": "node scripts/platform-dependencies", "postinstall": "node scripts/postinstall", "setup": "cd scripts && yarn install", "build-web": "NODE_ENV=production node web/esbuild.config.mjs", - "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs" + "run-web": "NODE_ENV=development node web/esbuild.config.dev.mjs", + "lint-extensions": "eslint extensions/*/app --max-warnings 0" }, "dependencies": { "auto-bind": "4.0.0", @@ -19,10 +20,9 @@ "lodash": "4.17.21", "moment": "2.29.4", "prop-types": "15.7.1", - "react": "19.0.0", - "react-dom": "19.0.0", + "react": "19.1.4", + "react-dom": "19.1.4", "react-native-web": "0.21.2", - "@react-native/assets-registry": "0.85.3", "@babel/plugin-proposal-export-namespace-from": "7.18.9", "react-redux": "7.2.6", "redux": "3.6.0", @@ -39,11 +39,11 @@ "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", "@babel/plugin-proposal-decorators": "7.20.13", - "@react-native/babel-preset": "0.78.0", - "@react-native/eslint-config": "0.78.0", - "@react-native/metro-config": "0.78.0", + "@react-native/babel-preset": "0.81.6", + "@react-native/eslint-config": "0.81.6", + "@react-native/metro-config": "0.81.6", "@shoutem/build-tools": "file:scripts/helpers", - "@shoutem/eslint-config-react-native": "~1.0.2", + "@shoutem/eslint-config-react-native": "~1.1.0-rc.0", "@welldone-software/why-did-you-render": "10.0.1", "babel-plugin-jest-hoist": "24.9.0", "eslint-plugin-import": "2.25.4", @@ -58,10 +58,10 @@ "prettier": "2.8.8", "patch-package": "6.4.7", "react-native-asset": "2.1.1", - "react-test-renderer": "19.0.0", - "@react-native-community/cli": "15.0.1", - "@react-native-community/cli-platform-android": "15.0.1", - "@react-native-community/cli-platform-ios": "15.0.1", + "react-test-renderer": "19.1.0", + "@react-native-community/cli": "20.0.0", + "@react-native-community/cli-platform-android": "20.0.0", + "@react-native-community/cli-platform-ios": "20.0.0", "cssnano": "^7.0.4", "sass": "^1.77.7", "esbuild": "0.23.1", @@ -112,6 +112,6 @@ "preset": "module:react-native" }, "engines": { - "node": ">=18" + "node": ">=20" } } diff --git a/patches/react-native+0.72.4.patch b/patches/react-native+0.81.6.patch similarity index 82% rename from patches/react-native+0.72.4.patch rename to patches/react-native+0.81.6.patch index 1e9267d566..3cea6305f6 100644 --- a/patches/react-native+0.72.4.patch +++ b/patches/react-native+0.81.6.patch @@ -2,11 +2,11 @@ diff --git a/node_modules/react-native/scripts/find-node-for-xcode.sh b/node_mod index e928cd8..4f976e4 100644 --- a/node_modules/react-native/scripts/find-node-for-xcode.sh +++ b/node_modules/react-native/scripts/find-node-for-xcode.sh -@@ -20,6 +20,7 @@ set -e +@@ -19,6 +19,7 @@ # remove global prefix if it's already set # the running shell process will choose a node binary and a global package directory breaks version managers unset PREFIX +unset npm_config_prefix - # Support Homebrew on M1 - HOMEBREW_M1_BIN=/opt/homebrew/bin + # Support Homebrew on Apple Silicon + HOMEBREW_APPLE_SILICON_BIN=/opt/homebrew/bin diff --git a/patches/react-native-screens+3.37.0.patch b/patches/react-native-screens+3.37.0.patch deleted file mode 100644 index 13394fbb72..0000000000 --- a/patches/react-native-screens+3.37.0.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/node_modules/react-native-screens/android/build.gradle b/node_modules/react-native-screens/android/build.gradle -index bbca48a..39aa019 100644 ---- a/node_modules/react-native-screens/android/build.gradle -+++ b/node_modules/react-native-screens/android/build.gradle -@@ -82,7 +82,9 @@ android { - externalNativeBuild { - cmake { - arguments "-DANDROID_STL=c++_shared", -- "-DRNS_NEW_ARCH_ENABLED=${IS_NEW_ARCHITECTURE_ENABLED}" -+ "-DRNS_NEW_ARCH_ENABLED=${IS_NEW_ARCHITECTURE_ENABLED}", -+ // 16KB alignment with NDK 27: https://github.com/software-mansion/react-native-screens/issues/2996#issuecomment-3214382789 -+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" - } - } - } diff --git a/platform/platform.json b/platform/platform.json index 9cc8a0185e..7c8d3cb922 100644 --- a/platform/platform.json +++ b/platform/platform.json @@ -1,90 +1,90 @@ { - "version": "17.0.11", - "releaseNotes": "Builder release notes\n* ๐Ÿ› Fixed the app's web preview failing to build.\n\nDeveloper notes\n* ๐Ÿ“ฆ Pinned react-native to 0.85.3 via resolutions and added @react-native/assets-registry as an explicit dependency in the web package template โ€” react-native 0.87.0 (new npm latest) dropped @react-native/assets-registry, which broke web bundle builds.", + "version": "18.0.0-rc.2", + "releaseNotes": "Developer notes\n* ๐Ÿค– Android now compiles against and targets API level 36 (Android 16), with updated Kotlin and Gradle tooling.\n* ๐Ÿ“ฆ Upgraded the platform to React Native 0.81 and React 19.\n* ๐Ÿงน Platform-wide lint modernization and internal cleanup.", "builds": { "live": { "excludedPackages": [] } }, "nativeChanges": { - "lastVersion": "17.0.9" + "lastVersion": "18.0.0-rc.2" }, "dependencies": { - "shoutem.about": "~9.1.0", - "shoutem.advertising": "~8.1.0", - "shoutem.affiliate": "~4.0.0", - "shoutem.age-restriction": "~2.0.1", - "shoutem.agora": "~7.1.0", - "shoutem.analytics": "~5.2.0", - "shoutem.application": "~7.6.1", - "shoutem.audio": "~9.0.2", - "shoutem.auth": "~11.0.3", - "shoutem.bands-in-town": "~2.0.0", - "shoutem.besttime": "~6.0.1", - "shoutem.books": "~7.1.0", - "shoutem.camera": "~6.3.0", - "shoutem.carplay": "~2.0.0", - "shoutem.checklist": "~6.1.0", - "shoutem.cms": "~8.2.1", - "shoutem.deals": "~8.1.1", - "shoutem.events": "~6.2.1", - "shoutem.favorites": "~7.0.1", - "shoutem.firebase": "~7.0.0", - "shoutem.fonts": "~3.0.0", - "shoutem.geo-restrictions": "~5.1.3", - "shoutem.google-analytics": "~4.1.0", - "shoutem.i18n": "~5.0.1", - "shoutem.ical-events": "~6.0.2", + "shoutem.about": "~9.1.1-rc.1", + "shoutem.advertising": "~8.1.1-rc.1", + "shoutem.affiliate": "~4.0.1-rc.1", + "shoutem.age-restriction": "~2.0.2-rc.1", + "shoutem.agora": "~7.1.1-rc.1", + "shoutem.analytics": "~5.2.1-rc.1", + "shoutem.application": "~7.6.2-rc.1", + "shoutem.audio": "~9.0.3-rc.1", + "shoutem.auth": "~11.0.4-rc.1", + "shoutem.bands-in-town": "~2.0.1-rc.1", + "shoutem.besttime": "~6.0.2-rc.1", + "shoutem.books": "~7.1.1-rc.1", + "shoutem.camera": "~6.3.1-rc.1", + "shoutem.carplay": "~2.0.1-rc.1", + "shoutem.checklist": "~6.1.1-rc.1", + "shoutem.cms": "~8.2.2-rc.1", + "shoutem.deals": "~8.1.2-rc.1", + "shoutem.events": "~6.2.2-rc.1", + "shoutem.favorites": "~7.0.2-rc.1", + "shoutem.firebase": "~7.0.1-rc.1", + "shoutem.fonts": "~3.0.1-rc.1", + "shoutem.geo-restrictions": "~5.1.4-rc.1", + "shoutem.google-analytics": "~4.1.1-rc.1", + "shoutem.i18n": "~5.0.2-rc.1", + "shoutem.ical-events": "~6.0.3-rc.1", "shoutem.importers": "~1.0.1", - "shoutem.in-app-purchases": "~7.3.0", - "shoutem.interactive-faq": "~8.1.0", - "shoutem.layouts": "~8.1.2", - "shoutem.live-update": "~2.1.1", - "shoutem.loyalty": "~8.1.0", - "shoutem.menu": "~6.1.0", - "shoutem.navigation": "~7.5.0", - "shoutem.news": "~7.3.0", - "shoutem.notification-center": "~12.2.2", - "shoutem.onboarding": "~6.0.0", - "shoutem.page": "~6.1.0", - "shoutem.people": "~6.1.0", - "shoutem.permissions": "~7.2.0", - "shoutem.persist": "~5.0.0", - "shoutem.photos": "~6.1.0", - "shoutem.places": "~8.4.1", - "shoutem.podcast": "~13.1.3", - "shoutem.preview": "~7.1.1", - "shoutem.products": "~6.1.0", - "shoutem.push-notifications": "~5.1.1", - "shoutem.questionnaire": "~1.1.0", - "shoutem.radio-player": "~12.1.5", - "shoutem.recombee": "~3.0.0", - "shoutem.redux": "~5.0.3", - "shoutem.rss": "~6.0.1", + "shoutem.in-app-purchases": "~7.3.1-rc.1", + "shoutem.interactive-faq": "~8.1.1-rc.1", + "shoutem.layouts": "~8.1.3-rc.1", + "shoutem.live-update": "~2.1.2-rc.1", + "shoutem.loyalty": "~8.1.1-rc.1", + "shoutem.menu": "~6.1.1-rc.1", + "shoutem.navigation": "~7.5.1-rc.0", + "shoutem.news": "~7.3.1-rc.1", + "shoutem.notification-center": "~12.2.3-rc.1", + "shoutem.onboarding": "~6.0.1-rc.1", + "shoutem.page": "~6.1.1-rc.1", + "shoutem.people": "~6.1.1-rc.1", + "shoutem.permissions": "~7.2.1-rc.1", + "shoutem.persist": "~5.0.1-rc.1", + "shoutem.photos": "~6.1.1-rc.1", + "shoutem.places": "~8.4.2-rc.1", + "shoutem.podcast": "~13.1.4-rc.1", + "shoutem.preview": "~7.1.2-rc.1", + "shoutem.products": "~6.1.1-rc.1", + "shoutem.push-notifications": "~5.1.2-rc.1", + "shoutem.questionnaire": "~1.1.1-rc.1", + "shoutem.radio-player": "~12.1.6-rc.1", + "shoutem.recombee": "~3.0.1-rc.1", + "shoutem.redux": "~5.0.4-rc.1", + "shoutem.rss": "~6.0.2-rc.1", "shoutem.rss-monitoring": "~5.0.1", - "shoutem.rss-news": "~6.0.0", - "shoutem.rss-photos": "~6.0.1", - "shoutem.rss-videos": "~6.0.2", - "shoutem.rubicon-theme": "~7.1.0", - "shoutem.sendbird": "~7.0.2", - "shoutem.shopify": "~11.2.0", - "shoutem.social": "~16.0.1", + "shoutem.rss-news": "~6.0.1-rc.1", + "shoutem.rss-photos": "~6.0.2-rc.1", + "shoutem.rss-videos": "~6.0.3-rc.1", + "shoutem.rubicon-theme": "~7.1.1-rc.1", + "shoutem.sendbird": "~7.0.3-rc.1", + "shoutem.shopify": "~11.2.1-rc.1", + "shoutem.social": "~16.0.2-rc.1", "shoutem.sub-navigation": "~5.1.0", - "shoutem.theme": "~9.1.10", - "shoutem.user-profile": "~6.3.3", - "shoutem.video": "~8.1.0", - "shoutem.web-view": "~10.1.1", - "shoutem.wordpress": "~6.0.1", - "shoutem.youtube": "~6.0.1" + "shoutem.theme": "~9.1.11-rc.1", + "shoutem.user-profile": "~6.3.4-rc.1", + "shoutem.video": "~8.1.1-rc.1", + "shoutem.web-view": "~10.1.2-rc.1", + "shoutem.wordpress": "~6.0.2-rc.1", + "shoutem.youtube": "~6.0.2-rc.1" }, "requiredDependencies": { - "shoutem.application": "~7.6.1", - "shoutem.i18n": "~5.0.1", - "shoutem.layouts": "~8.1.2", - "shoutem.navigation": "~7.5.0", - "shoutem.redux": "~5.0.3", + "shoutem.application": "~7.6.2-rc.1", + "shoutem.i18n": "~5.0.2-rc.1", + "shoutem.layouts": "~8.1.3-rc.1", + "shoutem.navigation": "~7.5.1-rc.0", + "shoutem.redux": "~5.0.4-rc.1", "shoutem.sub-navigation": "~5.1.0", - "shoutem.theme": "~9.1.10" + "shoutem.theme": "~9.1.11-rc.1" }, "archivedDependencies": [ "shoutem.flurry-analytics", diff --git a/scripts/classes/app-binary-configurator.js b/scripts/classes/app-binary-configurator.js index de481412d2..bcd39773da 100644 --- a/scripts/classes/app-binary-configurator.js +++ b/scripts/classes/app-binary-configurator.js @@ -41,18 +41,148 @@ function renamePath(oldPath, newPath) { return fs.rename(oldPath, newPath); } -function downloadImage(imageUrl, savePath) { - const assetsDir = path.resolve(rootProjectDir, 'assets'); - fs.ensureDirSync(assetsDir); +const IMAGE_DOWNLOAD_ATTEMPTS = 3; +const IMAGE_DOWNLOAD_RETRY_DELAY = 1000; +/** + * Downloads a single image to disk. Rejects on transport errors (the `request` + * object is where connection-level failures such as ECONNRESET surface) as well + * as on write errors, and treats any non-2xx response as a failure instead of + * silently writing an error page to disk. + * + * @param imageUrl The image URL to download. + * @param savePath Destination path on disk. Removed if the download fails. + * @returns {Promise} Resolves with savePath once fully written. + */ +function downloadImageOnce(imageUrl, savePath) { return new Promise((resolve, reject) => { - request(imageUrl) + let settled = false; + + const fail = err => { + if (settled) { + return; + } + + settled = true; + fs.remove(savePath, () => reject(err)); + }; + + const req = request(imageUrl); + + // Connection-level failures (ECONNRESET, socket hang up, DNS) are emitted + // on the request, not on the write stream it is piped into. Without this + // listener node treats them as an unhandled 'error' event and aborts the + // whole process. + req.on('error', fail); + + req.on('response', response => { + if (response.statusCode < 200 || response.statusCode > 299) { + req.abort(); + fail( + new Error( + `Download of ${imageUrl} failed with status ${response.statusCode}.`, + ), + ); + } + }); + + req .pipe(fs.createWriteStream(savePath)) - .on('error', () => reject()) - .on('finish', () => resolve(savePath)); + .on('error', fail) + .on('finish', () => { + if (!settled) { + settled = true; + resolve(savePath); + } + }); + }); +} + +const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + +/** + * Ensures a file saved under a `.png` path actually contains PNG-encoded + * bytes. The Builder lets app owners upload icons in any format (JPEG is + * common) but always serves them under a `.png` name, and release-mode + * AAPT2 rejects such misnamed files with "file failed to compile". If the + * bytes aren't a valid PNG, the file is re-encoded in place with Jimp. + * + * @param imagePath Path to the downloaded image on disk. + * @returns {Promise} Resolves with imagePath once it's a valid PNG + * (or left untouched if it isn't a `.png` path, is already a valid PNG, or + * re-encoding fails). + */ +function ensurePngFormat(imagePath) { + if (!/\.png$/i.test(imagePath)) { + return Promise.resolve(imagePath); + } + + return fs.readFile(imagePath).then(buffer => { + if (buffer.slice(0, 4).equals(PNG_MAGIC_BYTES)) { + return imagePath; + } + + return Jimp.read(imagePath) + .then( + image => + new Promise((resolve, reject) => { + image.write(imagePath, err => { + if (err) { + reject(err); + return; + } + + resolve(imagePath); + }); + }), + ) + .catch(error => { + // eslint-disable-next-line no-console + console.warn( + '[platform] app-binary-configurator.ensurePngFormat: could not re-encode image as PNG, leaving file as downloaded.', + error, + ); + + return imagePath; + }); }); } +/** + * Downloads an image, retrying a few times when the failure looks transient. + * + * @param imageUrl The image URL to download. + * @param savePath Destination path on disk. + * @param attemptsLeft Remaining attempts, including the current one. + * @returns {Promise} Resolves with savePath once fully written. + */ +function downloadImage( + imageUrl, + savePath, + attemptsLeft = IMAGE_DOWNLOAD_ATTEMPTS, +) { + const assetsDir = path.resolve(rootProjectDir, 'assets'); + fs.ensureDirSync(assetsDir); + + return downloadImageOnce(imageUrl, savePath) + .then(ensurePngFormat) + .catch(err => { + if (attemptsLeft <= 1) { + throw err; + } + + // eslint-disable-next-line no-console + console.warn( + `[platform] app-binary-configurator.downloadImage: download of ${imageUrl} failed, retrying.`, + err, + ); + + return new Promise(resolve => + setTimeout(resolve, IMAGE_DOWNLOAD_RETRY_DELAY), + ).then(() => downloadImage(imageUrl, savePath, attemptsLeft - 1)); + }); +} + function parsePlist(plistPath) { const plistContent = fs.readFileSync(plistPath, 'utf8'); @@ -631,12 +761,23 @@ class AppBinaryConfigurator { return Promise.resolve(); } + /** + * Runs the given configure step once per platform and waits for all of them. + * The per-platform result is returned so async steps (asset downloads) are + * actually awaited - otherwise their failures surface much later, detached + * from the step that started them. + * + * @param configureFunction Step to run, called with (settings, platform). + * @returns {Promise} Resolves once every platform's step settles. + */ runForAllPlatforms(configureFunction) { return Promise.all( _.map(binarySettings, (settings, platform) => { if (_.isFunction(configureFunction)) { - configureFunction(_.result(binarySettings, platform), platform); + return configureFunction(_.result(binarySettings, platform), platform); } + + return Promise.resolve(); }), ); } diff --git a/scripts/collect-ci-dependencies.js b/scripts/collect-ci-dependencies.js new file mode 100644 index 0000000000..7f70ae6217 --- /dev/null +++ b/scripts/collect-ci-dependencies.js @@ -0,0 +1,66 @@ +/** + * CI-only helper: merges the platform template's and every extension's app + * dependencies into the root package.json so a plain `npm install` produces a + * dependency tree equivalent to a configured app (minus extension linking). + * + * Used by the lint GitHub Action, which runs on an un-configured checkout + * where the committed package.json is the slim pre-configure one โ€” without + * this, packages that babel.config.js needs (e.g. react-native-reanimated) + * are missing and ESLint fails to parse every file. + * + * Mirrors the merge rule of scripts/get-app-dependencies.js: extensions are + * processed in sorted order and a later dependency entry wins on conflicts. + * + * Intentionally dependency-free (runs before any npm install). The modified + * package.json is a CI artifact โ€” never commit it. + */ +const fs = require('fs'); +const path = require('path'); + +const projectPath = path.resolve(__dirname, '..'); +const packageJsonPath = path.join(projectPath, 'package.json'); +const templateJsonPath = path.join(projectPath, 'package.template.json'); +const extensionsPath = path.join(projectPath, 'extensions'); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function collectCiDependencies() { + const packageJson = readJson(packageJsonPath); + const templateJson = readJson(templateJsonPath); + + let dependencies = { ...packageJson.dependencies, ...templateJson.dependencies }; + + const extensionDirs = fs + .readdirSync(extensionsPath) + .filter(dir => fs.statSync(path.join(extensionsPath, dir)).isDirectory()) + .sort(); + + let mergedExtensionsCount = 0; + + extensionDirs.forEach(dir => { + const appPackageJsonPath = path.join(extensionsPath, dir, 'app', 'package.json'); + + if (!fs.existsSync(appPackageJsonPath)) { + return; + } + + const appPackageJson = readJson(appPackageJsonPath); + + if (appPackageJson.dependencies) { + dependencies = { ...dependencies, ...appPackageJson.dependencies }; + mergedExtensionsCount += 1; + } + }); + + packageJson.dependencies = dependencies; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + + console.log( + `Merged ${Object.keys(dependencies).length} dependencies into package.json ` + + `(template + ${mergedExtensionsCount} extensions). Do not commit this file.`, + ); +} + +collectCiDependencies(); diff --git a/scripts/helpers/files.js b/scripts/helpers/files.js index 612de6b964..60715c26b3 100644 --- a/scripts/helpers/files.js +++ b/scripts/helpers/files.js @@ -15,20 +15,91 @@ function writeJsonToFile(filePath, json) { }); } -function downloadFile(url, path) { +const DOWNLOAD_ATTEMPTS = 3; +const DOWNLOAD_RETRY_DELAY = 1000; + +/** + * Downloads a single file to disk. Rejects on transport errors (the `request` + * object is where connection-level failures such as ECONNRESET surface) as well + * as on write errors, and treats any non-2xx response as a failure instead of + * silently writing an error page to disk. + * + * @param url The file URL to download. + * @param path Destination path on disk. Removed if the download fails. + * @returns {Promise} Resolves once the file is fully written. + */ +function downloadFileOnce(url, path) { return new Promise((resolve, reject) => { - request(url) + let settled = false; + + const fail = err => { + if (settled) { + return; + } + + settled = true; + fs.remove(path, () => reject(err)); + }; + + const req = request(url); + + // Connection-level failures (ECONNRESET, socket hang up, DNS) are emitted + // on the request, not on the write stream it is piped into. Without this + // listener node treats them as an unhandled 'error' event and aborts the + // whole process. + req.on('error', fail); + + req.on('response', response => { + if (response.statusCode < 200 || response.statusCode > 299) { + req.abort(); + fail( + new Error( + `Download of ${url} failed with status ${response.statusCode}.`, + ), + ); + } + }); + + req .pipe(fs.createWriteStream(path)) + .on('error', fail) .on('close', () => { - resolve(); - }) - .on('error', err => { - fs.unlink(path); - reject(err); + if (!settled) { + settled = true; + resolve(); + } }); }); } +/** + * Downloads a file, retrying a few times when the failure looks transient. + * Configure issues many concurrent downloads, and remote hosts intermittently + * reset those connections - a retry is far cheaper than failing the whole build. + * + * @param url The file URL to download. + * @param path Destination path on disk. + * @param attemptsLeft Remaining attempts, including the current one. + * @returns {Promise} Resolves once the file is fully written. + */ +function downloadFile(url, path, attemptsLeft = DOWNLOAD_ATTEMPTS) { + return downloadFileOnce(url, path).catch(err => { + if (attemptsLeft <= 1) { + throw err; + } + + // eslint-disable-next-line no-console + console.warn( + `[platform] files.downloadFile: download of ${url} failed, retrying.`, + err, + ); + + return new Promise(resolve => + setTimeout(resolve, DOWNLOAD_RETRY_DELAY), + ).then(() => downloadFile(url, path, attemptsLeft - 1)); + }); +} + exports.writeFile = writeFile; exports.writeJsonToFile = writeJsonToFile; exports.downloadFile = downloadFile; diff --git a/scripts/helpers/find-file-on-path.js b/scripts/helpers/find-file-on-path.js index 1020fdff1e..4379cb13d4 100644 --- a/scripts/helpers/find-file-on-path.js +++ b/scripts/helpers/find-file-on-path.js @@ -4,9 +4,38 @@ const _ = require('lodash'); const glob = require('glob'); const { slash } = require('./path'); +// Directories that can contain files matching the same names as the app's own native +// files (Pods generates its own project.pbxproj and hundreds of Info.plist files, build +// output mirrors the app tree). Without ignoring them, a stale ios/Pods left over from a +// previous configure wins the glob - 'ios/Pods' sorts before 'ios/ShoutemApp' - and every +// caller silently resolves the wrong file. +// +// The patterns are anchored under `sourcePath` (see findFileOnPath) so only directories +// *below* the search root are ignored. The app itself may live in a directory with one of +// these names (build machines clone it into `build/`), and an unanchored `**/build/**` +// would then exclude every file and make every caller resolve null. +const IGNORED_DIRECTORIES = [ + '**/Pods/**', + '**/build/**', + '**/node_modules/**', + '**/DerivedData/**', +]; + +/** + * Finds the first file matching the given name anywhere under the source path, ignoring + * generated and vendored directories below it so that only the app's own native files + * match. + * + * @param {String} fileName File name to search for; may be a glob (e.g. '*.xcworkspace'). + * @param {String} sourcePath Directory to search recursively. + * @returns {String|null} Path to the first match, or null when nothing matches. + */ function findFileOnPath(fileName, sourcePath) { const searchGlob = slash(`${sourcePath}/**/${fileName}`); - const foundFiles = glob.sync(searchGlob); + const ignore = IGNORED_DIRECTORIES.map(pattern => + slash(`${sourcePath}/${pattern}`), + ); + const foundFiles = glob.sync(searchGlob, { ignore }); if (_.isEmpty(foundFiles)) { return null; diff --git a/scripts/lint.js b/scripts/lint.js new file mode 100644 index 0000000000..d3bc6825d6 --- /dev/null +++ b/scripts/lint.js @@ -0,0 +1,62 @@ +/** + * Lints extension app code. + * + * Usage: + * npm run lint โ€“ lint every extension's app dir + * npm run lint audio places โ€“ lint only shoutem.audio and shoutem.places + * npm run lint shoutem.audio โ€“ full extension names work too + * npm run lint audio -- --fix โ€“ flags are passed through to eslint + * + * Extension names resolve to extensions//app; short names get the + * `shoutem.` prefix. Dependency-free so it runs on un-configured checkouts. + */ +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const projectPath = path.resolve(__dirname, '..'); +const extensionsPath = path.join(projectPath, 'extensions'); + +const args = process.argv.slice(2); +const flags = args.filter(arg => arg.startsWith('-')); +const names = args.filter(arg => !arg.startsWith('-')); + +/** + * Resolves a user-supplied extension name (short or full) to its app + * directory, exiting with a helpful message when it doesn't exist. + * + * @param {string} name Extension name, with or without the `shoutem.` prefix. + * @returns {string} Path to the extension's app directory, relative to root. + */ +function resolveExtensionAppDir(name) { + const candidates = name.includes('.') ? [name] : [`shoutem.${name}`, name]; + const found = candidates.find(candidate => + fs.existsSync(path.join(extensionsPath, candidate, 'app')), + ); + + if (!found) { + console.error(`Unknown extension: ${name} (tried ${candidates.join(', ')})`); + process.exit(1); + } + + return path.join('extensions', found, 'app'); +} + +let targets; +if (names.length > 0) { + targets = names.map(resolveExtensionAppDir); +} else { + targets = fs + .readdirSync(extensionsPath) + .filter(dir => fs.existsSync(path.join(extensionsPath, dir, 'app'))) + .sort() + .map(dir => path.join('extensions', dir, 'app')); +} + +const result = spawnSync( + 'npx', + ['eslint', ...targets, '--max-warnings', '0', ...flags], + { cwd: projectPath, stdio: 'inherit' }, +); + +process.exit(result.status === null ? 1 : result.status);