diff --git a/.circleci/config.yml b/.circleci/config.yml index c44a1c76f..5bd327dba 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,5 +1,206 @@ version: 2.1 +executors: + android: + parameters: + resource_class: + default: large + type: string + environment: + NODE_OPTIONS: --max_old_space_size=12000 + NODE_ENV: production + BABEL_ENV: production + docker: + - image: circleci/android:api-27-node + working_directory: ~/mattermost-mobile + resource_class: <> + + ios: + environment: + NODE_OPTIONS: --max_old_space_size=12000 + NODE_ENV: production + BABEL_ENV: production + macos: + xcode: "11.0.0" + working_directory: ~/mattermost-mobile + shell: /bin/bash --login -o pipefail + +commands: + checkout-private: + description: "Checkout the private repo with build env vars" + steps: + - add_ssh_keys: + fingerprints: + - "59:4d:99:5e:1c:6d:30:36:6d:60:76:88:ff:a7:ab:63" + - run: + name: Clone the mobile private repo + command: git clone git@github.com:mattermost/mattermost-mobile-private.git ~/mattermost-mobile-private + + fastlane-dependencies: + description: "Get Fastlane dependencies" + parameters: + for: + type: string + steps: + - restore_cache: + name: Restore Fastlane cache + key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }} + - run: + working_directory: fastlane + name: Download Fastlane dependencies + command: bundle install --path vendor/bundle + - save_cache: + name: Save Fastlane cache + key: v1-gems-<< parameters.for >>-{{ checksum "fastlane/Gemfile.lock" }}-{{ arch }} + paths: + - fastlane/vendor/bundle + + gradle-dependencies: + description: "Get Gradle dependencies" + steps: + - restore_cache: + name: Restore Gradle cache + key: v1-gradle-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }} + - run: + working_directory: android + name: Download Gradle dependencies + command: ./gradlew dependencies + - save_cache: + name: Save Gradle cache + paths: + - ~/.gradle + key: v1-gradle-{{ checksum "android/build.gradle" }}-{{ checksum "android/app/build.gradle" }} + + assets: + description: "Generate app assets" + steps: + - restore_cache: + name: Restore assets cache + key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }} + - run: + name: Generate assets + command: make dist/assets + - save_cache: + name: Save assets cache + key: v1-assets-{{ checksum "assets/base/config.json" }}-{{ arch }} + paths: + - dist + + npm-dependencies: + description: "Get JavaScript dependencies" + steps: + - restore_cache: + name: Restore npm cache + key: v1-npm-{{ checksum "package.json" }}-{{ arch }} + - run: + name: Getting JavaScript dependencies + command: NODE_ENV=development npm install + - save_cache: + name: Save npm cache + key: v1-npm-{{ checksum "package.json" }}-{{ arch }} + paths: + - node_modules + + pods-dependencies: + description: "Get cocoapods dependencies" + steps: + - restore_cache: + name: Restore cocoapods specs and pods + key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }} + - run: + name: Getting cocoapods dependencies + working_directory: ios + command: pod install + - save_cache: + name: Save cocoapods specs and pods cache + key: v1-cocoapods-{{ checksum "ios/Podfile.lock" }}-{{ arch }} + paths: + - ios/Pods + - ~/.cocoapods + + build-android: + description: "Build the android app" + steps: + - checkout: + path: ~/mattermost-mobile + - checkout-private + - npm-dependencies + - assets + - fastlane-dependencies: + for: android + - gradle-dependencies + - run: + name: Append Keystore to build Android + command: | + cp ~/mattermost-mobile-private/android/${STORE_FILE} android/app/${STORE_FILE} + echo "" | tee -a android/gradle.properties > /dev/null + echo MATTERMOST_RELEASE_STORE_FILE=${STORE_FILE} | tee -a android/gradle.properties > /dev/null + echo ${STORE_ALIAS} | tee -a android/gradle.properties > /dev/null + echo ${STORE_PASSWORD} | tee -a android/gradle.properties > /dev/null + - run: + working_directory: fastlane + name: Run fastlane to build android + no_output_timeout: 30m + command: bundle exec fastlane android build || exit 1 + + build-ios: + description: "Build the iOS app" + steps: + - checkout: + path: ~/mattermost-mobile + - npm-dependencies + - pods-dependencies + - assets + - fastlane-dependencies: + for: ios + - run: + working_directory: fastlane + name: Run fastlane to build iOS + no_output_timeout: 30m + command: bundle exec fastlane ios build || exit 1 + + deploy-android: + description: "Deploy apk to Google Play" + parameters: + apk_path: + type: string + steps: + - attach_workspace: + at: ~/mattermost-mobile + - deploy: + name: "Deploy apk to Google Play" + working_directory: fastlane + command: bundle exec fastlane android deploy apk:../<> + + deploy-ios: + description: "Deploy ipa to TestFlight" + parameters: + ipa_path: + type: string + steps: + - attach_workspace: + at: ~/mattermost-mobile + - deploy: + name: "Deploy ipa to TestFlight" + working_directory: fastlane + command: bundle exec fastlane ios deploy ipa:../<> + + persist: + description: "Persist mattermost-mobile directory" + steps: + - persist_to_workspace: + root: ./ + paths: + - ./ + + save: + description: "Save binaries artifacts" + parameters: + filename: + type: string + steps: + - store_artifacts: + path: ~/mattermost-mobile/<> jobs: test: @@ -7,17 +208,201 @@ jobs: docker: - image: circleci/node:10 steps: - - checkout - - run: | - echo assets/base/config.json - cat assets/base/config.json - # Avoid installing pods - touch .podinstall - # Run tests - make test || exit 1 + - checkout: + path: ~/mattermost-mobile + - npm-dependencies + - assets + - run: + name: Check styles + command: npm run check + - run: + name: Running Tests + command: npm test + - run: + name: Check i18n + command: make i18n-extract-ci + + build-android-beta: + executor: android + steps: + - build-android + - persist + - save: + filename: "Mattermost_Beta.apk" + + build-android-release: + executor: android + steps: + - build-android + - persist + - save: + filename: "Mattermost.apk" + + build-android-pr: + executor: android + environment: + BRANCH_TO_BUILD: ${CIRCLE_BRANCH} + steps: + - build-android + - persist + - save: + filename: "Mattermost_Beta.apk" + + build-ios-beta: + executor: ios + steps: + - build-ios + - persist + - save: + filename: "Mattermost_Beta.ipa" + + build-ios-release: + executor: ios + steps: + - build-ios + - persist + - save: + filename: "Mattermost.ipa" + + build-ios-pr: + executor: ios + environment: + BRANCH_TO_BUILD: ${CIRCLE_BRANCH} + steps: + - build-ios + - persist + - save: + filename: "Mattermost_Beta.ipa" + + deploy-android-release: + executor: + name: android + resource_class: medium + steps: + - deploy-android: + apk_path: Mattermost.apk + + deploy-android-beta: + executor: + name: android + resource_class: medium + steps: + - deploy-android: + apk_path: Mattermost_Beta.apk + + deploy-ios-release: + executor: ios + steps: + - deploy-ios: + ipa_path: Mattermost.ipa + + deploy-ios-beta: + executor: ios + steps: + - deploy-ios: + ipa_path: Mattermost_Beta.ipa workflows: version: 2 - pr-test: + build: jobs: - test + + - build-android-release: + context: mattermost-mobile-android-release + requires: + - test + filters: + branches: + only: + - /^build-\d+$/ + - /^build-android-\d+$/ + - /^build-android-release-\d+$/ + - deploy-android-release: + context: mattermost-mobile-android-release + requires: + - build-android-release + filters: + branches: + only: + - /^build-\d+$/ + - /^build-android-\d+$/ + - /^build-android-release-\d+$/ + + - build-android-beta: + context: mattermost-mobile-android-beta + requires: + - test + filters: + branches: + only: + - /^build-\d+$/ + - /^build-android-\d+$/ + - /^build-android-beta-\d+$/ + - deploy-android-beta: + context: mattermost-mobile-android-beta + requires: + - build-android-beta + filters: + branches: + only: + - /^build-\d+$/ + - /^build-android-\d+$/ + - /^build-android-beta-\d+$/ + + - build-ios-release: + context: mattermost-mobile-ios-release + requires: + - test + filters: + branches: + only: + - /^build-\d+$/ + - /^build-ios-\d+$/ + - /^build-ios-release-\d+$/ + - deploy-ios-release: + context: mattermost-mobile-ios-release + requires: + - build-ios-release + filters: + branches: + only: + - /^build-\d+$/ + - /^build-ios-\d+$/ + - /^build-ios-release-\d+$/ + + - build-ios-beta: + context: mattermost-mobile-ios-beta + requires: + - test + filters: + branches: + only: + - /^build-\d+$/ + - /^build-ios-\d+$/ + - /^build-ios-beta-\d+$/ + - deploy-ios-beta: + context: mattermost-mobile-ios-beta + requires: + - build-ios-beta + filters: + branches: + only: + - /^build-\d+$/ + - /^build-ios-\d+$/ + - /^build-ios-beta-\d+$/ + + - build-android-pr: + context: mattermost-mobile-android-pr + requires: + - test + filters: + branches: + only: /^build-pr-.*/ + - build-ios-pr: + context: mattermost-mobile-ios-pr + requires: + - test + filters: + branches: + only: /^build-pr-.*/ diff --git a/.gitignore b/.gitignore index e8c5ae3c7..1526310cc 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,6 @@ build/ *.perspectivev3 !default.perspectivev3 xcuserdata -xcshareddata *.xccheckout *.moved-aside DerivedData @@ -31,7 +30,8 @@ DerivedData *.apk *.xcuserstate project.xcworkspace -xcshareddata/ +ios/Pods +.podinstall # Android/IntelliJ # @@ -85,10 +85,6 @@ ios/sentry.properties .nyc_output coverage -# Pods -.podinstall -ios/Pods/ - # Bundle artifact *.jsbundle diff --git a/Makefile b/Makefile index 4c3178a3f..38d55dcb1 100644 --- a/Makefile +++ b/Makefile @@ -64,25 +64,16 @@ check-style: node_modules ## Runs eslint clean: ## Cleans dependencies, previous builds and temp files @echo Cleaning started - @rm -rf node_modules @rm -f .podinstall + @rm -rf ios/Pods + @rm -rf node_modules @rm -rf dist @rm -rf ios/build - @rm -rf ios/Pods @rm -rf android/app/build @echo Cleanup finished post-install: - @# Need to copy custom RNDocumentPicker.m that implements direct access to the document picker in iOS - @cp ./native_modules/RNDocumentPicker.m node_modules/react-native-document-picker/ios/RNDocumentPicker/RNDocumentPicker.m - - @# Need to copy custom RNCookieManagerIOS.m that fixes a crash when cookies does not have expiration date set - @cp ./native_modules/RNCookieManagerIOS.m node_modules/react-native-cookies/ios/RNCookieManagerIOS/RNCookieManagerIOS.m - - @# Need to copy custom RNCNetInfo.m that checks for internet connectivity instead of reaching a host by default - @cp ./native_modules/RNCNetInfo.m node_modules/@react-native-community/netinfo/ios/RNCNetInfo.m - @rm -f node_modules/intl/.babelrc @# Hack to get react-intl and its dependencies to work with react-native @# Based off of https://github.com/este/este/blob/master/gulp/native-fix.js @@ -90,10 +81,6 @@ post-install: @sed -i'' -e 's|"./lib/locales": false|"./lib/locales": "./lib/locales"|g' node_modules/intl-messageformat/package.json @sed -i'' -e 's|"./lib/locales": false|"./lib/locales": "./lib/locales"|g' node_modules/intl-relativeformat/package.json @sed -i'' -e 's|"./locale-data/complete.js": false|"./locale-data/complete.js": "./locale-data/complete.js"|g' node_modules/intl/package.json - @if [ $(shell grep "const Platform" node_modules/react-native/Libraries/Lists/VirtualizedList.js | grep -civ grep) -eq 0 ]; then \ - sed $ -i'' -e "s|const ReactNative = require('ReactNative');|const ReactNative = require('ReactNative');`echo $\\\\\\r;`const Platform = require('Platform');|g" node_modules/react-native/Libraries/Lists/VirtualizedList.js; \ - fi - @sed -i'' -e 's|transform: \[{scaleY: -1}\],|...Platform.select({android: {transform: \[{perspective: 1}, {scaleY: -1}\]}, ios: {transform: \[{scaleY: -1}\]}}),|g' node_modules/react-native/Libraries/Lists/VirtualizedList.js @./node_modules/.bin/patch-package diff --git a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java index 4ac1bdaa5..1864ea1de 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotification.java @@ -20,7 +20,6 @@ import android.net.Uri; import android.os.Bundle; import android.os.Build; import android.provider.Settings.System; -import android.util.Log; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; @@ -34,7 +33,6 @@ import com.wix.reactnativenotifications.core.NotificationIntentAdapter; import com.wix.reactnativenotifications.core.AppLaunchHelper; import com.wix.reactnativenotifications.core.AppLifecycleFacade; import com.wix.reactnativenotifications.core.JsIOHelper; -import com.wix.reactnativenotifications.helpers.ApplicationBadgeHelper; import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME; @@ -49,6 +47,9 @@ public class CustomPushNotification extends PushNotification { private static final String PUSH_TYPE_CLEAR = "clear"; private static final String PUSH_TYPE_UPDATE_BADGE = "update_badge"; + private NotificationChannel mHighImportanceChannel; + private NotificationChannel mMinImportanceChannel; + private static Map channelIdToNotificationCount = new HashMap(); private static Map> channelIdToNotification = new HashMap>(); private static AppLifecycleFacade lifecycleFacade; @@ -58,6 +59,7 @@ public class CustomPushNotification extends PushNotification { public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) { super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper); this.context = context; + createNotificationChannels(); } public static void clearNotification(Context mContext, int notificationId, String channelId) { @@ -77,7 +79,6 @@ public class CustomPushNotification extends PushNotification { if (count != -1) { int total = CustomPushNotification.badgeCount - count; int badgeCount = total < 0 ? 0 : total; - ApplicationBadgeHelper.instance.setApplicationIconBadgeNumber(mContext.getApplicationContext(), badgeCount); CustomPushNotification.badgeCount = badgeCount; } } @@ -90,7 +91,6 @@ public class CustomPushNotification extends PushNotification { if (mContext != null) { final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); notificationManager.cancelAll(); - ApplicationBadgeHelper.instance.setApplicationIconBadgeNumber(mContext.getApplicationContext(), 0); } } @@ -158,59 +158,141 @@ public class CustomPushNotification extends PushNotification { digestNotification(); } - @Override - protected void postNotification(int id, Notification notification) { - boolean force = false; - Bundle bundle = notification.extras; - if (bundle != null) { - force = bundle.getBoolean("localTest"); - } - - if (!mAppLifecycleFacade.isAppVisible() || force) { - super.postNotification(id, notification); - } - } - @Override protected Notification.Builder getNotificationBuilder(PendingIntent intent) { - final Resources res = mContext.getResources(); - String packageName = mContext.getPackageName(); - NotificationPreferences notificationPreferences = NotificationPreferences.getInstance(mContext); - // First, get a builder initialized with defaults from the core class. final Notification.Builder notification = new Notification.Builder(mContext); - // If Android Oreo or above we need to register a channel - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - String CHANNEL_ID = "channel_01"; - String CHANNEL_NAME = "Mattermost notifications"; - - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, - CHANNEL_NAME, - NotificationManager.IMPORTANCE_HIGH); - channel.setShowBadge(true); - - final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.createNotificationChannel(channel); - notification.setChannelId(CHANNEL_ID); - } - Bundle bundle = mNotificationProps.asBundle(); - String version = bundle.getString("version"); + addNotificationExtras(notification, bundle); + setNotificationIcons(notification, bundle); + setNotificationMessagingStyle(notification, bundle); + setNotificationChannel(notification, bundle); + setNotificationBadgeIconType(notification); + + NotificationPreferences notificationPreferences = NotificationPreferences.getInstance(mContext); + setNotificationSound(notification, notificationPreferences); + setNotificationVibrate(notification, notificationPreferences); + setNotificationBlink(notification, notificationPreferences); + String channelId = bundle.getString("channel_id"); - String channelName = bundle.getString("channel_name"); - String senderName = bundle.getString("sender_name"); - String senderId = bundle.getString("sender_id"); - String postId = bundle.getString("post_id"); - String badge = bundle.getString("badge"); + int notificationId = channelId != null ? channelId.hashCode() : MESSAGE_NOTIFICATION_ID; + setNotificationNumber(notification, channelId); + setNotificationDeleteIntent(notification, notificationId); + addNotificationReplyAction(notification, notificationId, bundle); + + notification + .setContentIntent(intent) + .setVisibility(Notification.VISIBILITY_PRIVATE) + .setPriority(Notification.PRIORITY_HIGH) + .setAutoCancel(true); + + return notification; + } + + private void addNotificationExtras(Notification.Builder notification, Bundle bundle) { + Bundle userInfoBundle = bundle.getBundle("userInfo"); + if (userInfoBundle == null) { + userInfoBundle = new Bundle(); + } + + String channelId = bundle.getString("channel_id"); + userInfoBundle.putString("channel_id", channelId); + + notification.addExtras(userInfoBundle); + } + + private void setNotificationIcons(Notification.Builder notification, Bundle bundle) { String smallIcon = bundle.getString("smallIcon"); String largeIcon = bundle.getString("largeIcon"); - int notificationId = channelId != null ? channelId.hashCode() : MESSAGE_NOTIFICATION_ID; + int smallIconResId = getSmallIconResourceId(smallIcon); + notification.setSmallIcon(smallIconResId); + + int largeIconResId = getLargeIconResourceId(largeIcon); + final Resources res = mContext.getResources(); + Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId); + if (largeIconResId != 0 && (largeIconBitmap != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)) { + notification.setLargeIcon(largeIconBitmap); + } + } + + private int getSmallIconResourceId(String iconName) { + if (iconName == null) { + iconName = "ic_notification"; + } + + int resourceId = getIconResourceId(iconName); + + if (resourceId == 0) { + iconName = "ic_launcher"; + resourceId = getIconResourceId(iconName); + + if (resourceId == 0) { + resourceId = android.R.drawable.ic_dialog_info; + } + } + + return resourceId; + } + + private int getLargeIconResourceId(String iconName) { + if (iconName == null) { + iconName = "ic_launcher"; + } + + return getIconResourceId(iconName); + } + + private int getIconResourceId(String iconName) { + final Resources res = mContext.getResources(); + String packageName = mContext.getPackageName(); + String defType = "mipmap"; + + return res.getIdentifier(iconName, defType, packageName); + } + + private void setNotificationNumber(Notification.Builder notification, String channelId) { + Integer number = channelIdToNotificationCount.get(channelId); + if (number != null) { + number = 0; + } + notification.setNumber(number); + } + + private void setNotificationMessagingStyle(Notification.Builder notification, Bundle bundle) { + Notification.MessagingStyle messagingStyle = getMessagingStyle(bundle); + notification.setStyle(messagingStyle); + } + + private Notification.MessagingStyle getMessagingStyle(Bundle bundle) { + Notification.MessagingStyle messagingStyle; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + messagingStyle = new Notification.MessagingStyle(""); + } else { + String senderId = bundle.getString("sender_id"); + Person sender = new Person.Builder() + .setKey(senderId) + .setName("") + .build(); + messagingStyle = new Notification.MessagingStyle(sender); + } + + String conversationTitle = getConversationTitle(bundle); + setMessagingStyleConversationTitle(messagingStyle, conversationTitle, bundle); + addMessagingStyleMessages(messagingStyle, conversationTitle, bundle); + + return messagingStyle; + } + + private String getConversationTitle(Bundle bundle) { String title = null; + + String version = bundle.getString("version"); if (version != null && version.equals("v2")) { - title = channelName; + title = bundle.getString("channel_name"); } else { title = bundle.getString("title"); } @@ -220,149 +302,100 @@ public class CustomPushNotification extends PushNotification { title = mContext.getPackageManager().getApplicationLabel(appInfo).toString(); } - Bundle b = bundle.getBundle("userInfo"); - if (b == null) { - b = new Bundle(); - } - b.putString("channel_id", channelId); - notification.addExtras(b); - - int smallIconResId; - int largeIconResId; - - if (smallIcon != null) { - smallIconResId = res.getIdentifier(smallIcon, "mipmap", packageName); - } else { - smallIconResId = res.getIdentifier("ic_notification", "mipmap", packageName); - } - - if (smallIconResId == 0) { - smallIconResId = res.getIdentifier("ic_launcher", "mipmap", packageName); - - if (smallIconResId == 0) { - smallIconResId = android.R.drawable.ic_dialog_info; - } - } - - if (largeIcon != null) { - largeIconResId = res.getIdentifier(largeIcon, "mipmap", packageName); - } else { - largeIconResId = res.getIdentifier("ic_launcher", "mipmap", packageName); - } - - if (badge != null) { - int badgeCount = Integer.parseInt(badge); - CustomPushNotification.badgeCount = badgeCount; - notification.setNumber(badgeCount); - ApplicationBadgeHelper.instance.setApplicationIconBadgeNumber(mContext.getApplicationContext(), CustomPushNotification.badgeCount); - } + return title; + } + private void setMessagingStyleConversationTitle(Notification.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) { + String channelName = bundle.getString("channel_name"); + String senderName = bundle.getString("sender_name"); if (android.text.TextUtils.isEmpty(senderName)) { - senderName = getSenderName(senderName, channelName, bundle.getString("message")); + senderName = getSenderName(bundle); } - String personId = senderId; - if (!android.text.TextUtils.isEmpty(channelName)) { - personId = channelId; + if (conversationTitle != null && (!conversationTitle.startsWith("@") || channelName != senderName)) { + messagingStyle.setConversationTitle(conversationTitle); } + } - Notification.MessagingStyle messagingStyle; - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - messagingStyle = new Notification.MessagingStyle(""); - } else { - Person sender = new Person.Builder() - .setKey(senderId) - .setName("") - .build(); - messagingStyle = new Notification.MessagingStyle(sender); - } - - if (title != null && (!title.startsWith("@") || channelName != senderName)) { - messagingStyle - .setConversationTitle(title); - } + private void addMessagingStyleMessages(Notification.MessagingStyle messagingStyle, String conversationTitle, Bundle bundle) { + List bundleList; + String channelId = bundle.getString("channel_id"); List bundleArray = channelIdToNotification.get(channelId); - List list; if (bundleArray != null) { - list = new ArrayList(bundleArray); + bundleList = new ArrayList(bundleArray); } else { - list = new ArrayList(); - list.add(bundle); + bundleList = new ArrayList(); + bundleList.add(bundle); } - int listCount = list.size() - 1; - for (int i = listCount; i >= 0; i--) { - Bundle data = list.get(i); + int bundleCount = bundleList.size() - 1; + for (int i = bundleCount; i >= 0; i--) { + Bundle data = bundleList.get(i); String message = data.getString("message"); - String previousPersonName = getSenderName(data.getString("sender_name"), channelName, message); - String previousPersonId = data.getString("sender_id"); - - if (title == null || !android.text.TextUtils.isEmpty(previousPersonName)) { - message = removeSenderFromMessage(previousPersonName, channelName, message); + String senderId = data.getString("sender_id"); + Bundle userInfoBundle = data.getBundle("userInfo"); + String senderName = getSenderName(data); + if (userInfoBundle != null) { + boolean localPushNotificationTest = userInfoBundle.getBoolean("localTest"); + if (localPushNotificationTest) { + senderName = "Test"; + } } + if (conversationTitle == null || !android.text.TextUtils.isEmpty(senderName.trim())) { + message = removeSenderNameFromMessage(message, senderName); + } + + long timestamp = data.getLong("time"); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - messagingStyle.addMessage(message, data.getLong("time"), previousPersonName); + messagingStyle.addMessage(message, timestamp, senderName); } else { Person sender = new Person.Builder() - .setKey(previousPersonId) - .setName(previousPersonName) - .build(); - messagingStyle.addMessage(message, data.getLong("time"), sender); + .setKey(senderId) + .setName(senderName) + .build(); + messagingStyle.addMessage(message, timestamp, sender); } } + } - notification - .setContentIntent(intent) - .setGroupSummary(true) - .setStyle(messagingStyle) - .setSmallIcon(smallIconResId) - .setVisibility(Notification.VISIBILITY_PRIVATE) - .setPriority(Notification.PRIORITY_HIGH) - .setAutoCancel(true); + private void setNotificationChannel(Notification.Builder notification, Bundle bundle) { + // If Android Oreo or above we need to register a channel + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationChannel notificationChannel = mHighImportanceChannel; + + boolean localPushNotificationTest = false; + Bundle userInfoBundle = bundle.getBundle("userInfo"); + if (userInfoBundle != null) { + localPushNotificationTest = userInfoBundle.getBoolean("localTest"); + } + + if (mAppLifecycleFacade.isAppVisible() && !localPushNotificationTest) { + notificationChannel = mMinImportanceChannel; + } + + notification.setChannelId(notificationChannel.getId()); + } + + private void setNotificationBadgeIconType(Notification.Builder notification) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { notification.setBadgeIconType(Notification.BADGE_ICON_SMALL); } + } - // Let's add a delete intent when the notification is dismissed - Intent delIntent = new Intent(mContext, NotificationDismissService.class); - delIntent.putExtra(NOTIFICATION_ID, notificationId); - PendingIntent deleteIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, delIntent, mNotificationProps); - notification.setDeleteIntent(deleteIntent); - + private void setNotificationGroup(Notification.Builder notification) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - notification.setGroup(GROUP_KEY_MESSAGES); - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && postId != null) { - Intent replyIntent = new Intent(mContext, NotificationReplyBroadcastReceiver.class); - replyIntent.setAction(KEY_TEXT_REPLY); - replyIntent.putExtra(NOTIFICATION_ID, notificationId); - replyIntent.putExtra("pushNotification", bundle); - PendingIntent replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT); - - RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY) - .setLabel("Reply") - .build(); - - Notification.Action replyAction = new Notification.Action.Builder( - R.drawable.ic_notif_action_reply, "Reply", replyPendingIntent) - .addRemoteInput(remoteInput) - .setAllowGeneratedReplies(true) - .build(); - notification - .setShowWhen(true) - .addAction(replyAction); - } - - Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId); - if (largeIconResId != 0 && (largeIcon != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)) { - notification.setLargeIcon(largeIconBitmap); + .setGroup(GROUP_KEY_MESSAGES) + .setGroupSummary(true); } + } + private void setNotificationSound(Notification.Builder notification, NotificationPreferences notificationPreferences) { String soundUri = notificationPreferences.getNotificationSound(); if (soundUri != null) { if (soundUri != "none") { @@ -372,19 +405,62 @@ public class CustomPushNotification extends PushNotification { Uri defaultUri = System.DEFAULT_NOTIFICATION_URI; notification.setSound(defaultUri, AudioManager.STREAM_NOTIFICATION); } + } + private void setNotificationVibrate(Notification.Builder notification, NotificationPreferences notificationPreferences) { boolean vibrate = notificationPreferences.getShouldVibrate(); if (vibrate) { - // use the system default for vibration + // Use the system default for vibration notification.setDefaults(Notification.DEFAULT_VIBRATE); } + } + private void setNotificationBlink(Notification.Builder notification, NotificationPreferences notificationPreferences) { boolean blink = notificationPreferences.getShouldBlink(); if (blink) { notification.setLights(Color.CYAN, 500, 500); } + } - return notification; + private void setNotificationDeleteIntent(Notification.Builder notification, int notificationId) { + // Let's add a delete intent when the notification is dismissed + Intent delIntent = new Intent(mContext, NotificationDismissService.class); + delIntent.putExtra(NOTIFICATION_ID, notificationId); + PendingIntent deleteIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, delIntent, mNotificationProps); + notification.setDeleteIntent(deleteIntent); + } + + private void addNotificationReplyAction(Notification.Builder notification, int notificationId, Bundle bundle) { + String postId = bundle.getString("post_id"); + if (postId == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return; + } + + Intent replyIntent = new Intent(mContext, NotificationReplyBroadcastReceiver.class); + replyIntent.setAction(KEY_TEXT_REPLY); + replyIntent.putExtra(NOTIFICATION_ID, notificationId); + replyIntent.putExtra("pushNotification", bundle); + + PendingIntent replyPendingIntent = PendingIntent.getBroadcast( + mContext, + notificationId, + replyIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + + RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY) + .setLabel("Reply") + .build(); + + int icon = R.drawable.ic_notif_action_reply; + CharSequence title = "Reply"; + Notification.Action replyAction = new Notification.Action.Builder(icon, title, replyPendingIntent) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(true) + .build(); + + notification + .setShowWhen(true) + .addAction(replyAction); } private void notifyReceivedToJS() { @@ -393,34 +469,51 @@ public class CustomPushNotification extends PushNotification { private void cancelNotification(Bundle data, int notificationId) { final String channelId = data.getString("channel_id"); - CustomPushNotification.clearNotification(mContext.getApplicationContext(), notificationId, channelId); + final String badge = data.getString("badge"); - final String badgeString = data.getString("badge"); - CustomPushNotification.badgeCount = Integer.parseInt(badgeString); - ApplicationBadgeHelper.instance.setApplicationIconBadgeNumber(mContext.getApplicationContext(), CustomPushNotification.badgeCount); + CustomPushNotification.badgeCount = Integer.parseInt(badge); + CustomPushNotification.clearNotification(mContext.getApplicationContext(), notificationId, channelId); } - private String getSenderName(String senderName, String channelName, String message) { + private String getSenderName(Bundle bundle) { + String senderName = bundle.getString("sender_name"); if (senderName != null) { return senderName; - } else if (channelName != null && channelName.startsWith("@")) { + } + + String channelName = bundle.getString("channel_name"); + if (channelName != null && channelName.startsWith("@")) { return channelName; } - String name = message.split(":")[0]; - if (name != message) { - return name; + String message = bundle.getString("message"); + if (message != null) { + String name = message.split(":")[0]; + if (name != message) { + return name; + } } return " "; } - private String removeSenderFromMessage(String senderName, String channelName, String message) { - String sender = String.format("%s", getSenderName(senderName, channelName, message)); - return message.replaceFirst(sender, "").replaceFirst(": ", "").trim(); + private String removeSenderNameFromMessage(String message, String senderName) { + return message.replaceFirst(senderName, "").replaceFirst(": ", "").trim(); } private void notificationReceiptDelivery(String ackId, String type) { ReceiptDelivery.send(context, ackId, type); } + + private void createNotificationChannels() { + final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); + + mHighImportanceChannel = new NotificationChannel("channel_01", "High Importance", NotificationManager.IMPORTANCE_HIGH); + mHighImportanceChannel.setShowBadge(true); + notificationManager.createNotificationChannel(mHighImportanceChannel); + + mMinImportanceChannel = new NotificationChannel("channel_02", "Min Importance", NotificationManager.IMPORTANCE_MIN); + mMinImportanceChannel.setShowBadge(true); + notificationManager.createNotificationChannel(mMinImportanceChannel); + } } diff --git a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotificationDrawer.java b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotificationDrawer.java index 160a446be..7f3878091 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotificationDrawer.java +++ b/android/app/src/main/java/com/mattermost/rnbeta/CustomPushNotificationDrawer.java @@ -6,7 +6,7 @@ import com.wix.reactnativenotifications.core.AppLaunchHelper; import com.wix.reactnativenotifications.core.notificationdrawer.PushNotificationsDrawer; import com.wix.reactnativenotifications.core.notificationdrawer.IPushNotificationsDrawer; import com.wix.reactnativenotifications.core.notificationdrawer.INotificationsDrawerApplication; -import com.wix.reactnativenotifications.helpers.PushNotificationHelper; + import static com.wix.reactnativenotifications.Defs.LOGTAG; public class CustomPushNotificationDrawer extends PushNotificationsDrawer { diff --git a/android/settings.gradle b/android/settings.gradle index 68f0dd54c..30b886887 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -36,7 +36,7 @@ project(':react-native-cookies').projectDir = new File(rootProject.projectDir, ' include ':react-native-vector-icons' project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android') include ':reactnativenotifications' -project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/android') +project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/android/app') include ':app' include ':react-native-svg' diff --git a/app/components/profile_picture/profile_picture.js b/app/components/profile_picture/profile_picture.js index 61a20bcbc..82f6b63d5 100644 --- a/app/components/profile_picture/profile_picture.js +++ b/app/components/profile_picture/profile_picture.js @@ -67,27 +67,6 @@ export default class ProfilePicture extends PureComponent { } } - componentWillReceiveProps(nextProps) { - if (this.mounted) { - if (nextProps.edit && nextProps.imageUri && nextProps.imageUri !== this.props.imageUri) { - this.setImageURL(nextProps.imageUri); - return; - } - - if (nextProps.profileImageUri !== '' && nextProps.profileImageUri !== this.props.profileImageUri) { - this.setImageURL(nextProps.profileImageUri); - } - - const url = this.props.user ? Client4.getProfilePictureUrl(this.props.user.id, this.props.user.last_picture_update) : null; - const nextUrl = nextProps.user ? Client4.getProfilePictureUrl(nextProps.user.id, nextProps.user.last_picture_update) : null; - - if (nextUrl && url !== nextUrl) { - // empty function is so that promise unhandled is not triggered in dev mode - ImageCacheManager.cache('', nextUrl, this.setImageURL).then(this.clearProfileImageUri).catch(emptyFunction); - } - } - } - componentWillUnmount() { this.mounted = false; } @@ -107,6 +86,23 @@ export default class ProfilePicture extends PureComponent { componentDidUpdate(prevProps) { if (this.props.profileImageRemove !== prevProps.profileImageRemove) { this.setImageURL(null); + } else if (this.mounted) { + if (this.props.edit && this.props.imageUri && this.props.imageUri !== prevProps.imageUri) { + this.setImageURL(this.props.imageUri); + return; + } + + if (this.props.profileImageUri !== '' && this.props.profileImageUri !== prevProps.profileImageUri) { + this.setImageURL(this.props.profileImageUri); + } + + const url = prevProps.user ? Client4.getProfilePictureUrl(prevProps.user.id, prevProps.user.last_picture_update) : null; + const nextUrl = this.props.user ? Client4.getProfilePictureUrl(this.props.user.id, this.props.user.last_picture_update) : null; + + if (nextUrl && url !== nextUrl) { + // empty function is so that promise unhandled is not triggered in dev mode + ImageCacheManager.cache('', nextUrl, this.setImageURL).then(this.clearProfileImageUri).catch(emptyFunction); + } } } diff --git a/app/components/retry_bar_indicator/retry_bar_indicator.js b/app/components/retry_bar_indicator/retry_bar_indicator.js index 0cb943b55..c812cb5e1 100644 --- a/app/components/retry_bar_indicator/retry_bar_indicator.js +++ b/app/components/retry_bar_indicator/retry_bar_indicator.js @@ -19,9 +19,9 @@ export default class RetryBarIndicator extends PureComponent { retryMessageHeight: new Animated.Value(0), }; - componentWillReceiveProps(nextProps) { - if (this.props.failed !== nextProps.failed) { - this.toggleRetryMessage(nextProps.failed); + componentDidUpdate(prevProps) { + if (this.props.failed !== prevProps.failed) { + this.toggleRetryMessage(this.props.failed); } } diff --git a/app/components/safe_area_view/__snapshots__/safe_area_view.ios.test.js.snap b/app/components/safe_area_view/__snapshots__/safe_area_view.ios.test.js.snap new file mode 100644 index 000000000..de2a8784e --- /dev/null +++ b/app/components/safe_area_view/__snapshots__/safe_area_view.ios.test.js.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SafeAreaIos should match snapshot 1`] = ` + + + + +`; diff --git a/app/components/safe_area_view/safe_area_view.ios.js b/app/components/safe_area_view/safe_area_view.ios.js index ada7d83e4..b517e86ba 100644 --- a/app/components/safe_area_view/safe_area_view.ios.js +++ b/app/components/safe_area_view/safe_area_view.ios.js @@ -3,7 +3,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {Dimensions, Keyboard, NativeModules, View} from 'react-native'; +import {Keyboard, NativeModules, View} from 'react-native'; import SafeArea from 'react-native-safe-area'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; @@ -57,17 +57,16 @@ export default class SafeAreaIos extends PureComponent { componentDidMount() { this.mounted = true; - Dimensions.addEventListener('change', this.getSafeAreaInsets); + SafeArea.addEventListener('safeAreaInsetsForRootViewDidChange', this.onSafeAreaInsetsForRootViewChange); EventEmitter.on('update_safe_area_view', this.getSafeAreaInsets); this.keyboardDidShowListener = Keyboard.addListener('keyboardWillShow', this.keyboardWillShow); this.keyboardDidHideListener = Keyboard.addListener('keyboardWillHide', this.keyboardWillHide); this.getSafeAreaInsets(); - this.getStatusBarHeight(); } componentWillUnmount() { - Dimensions.removeEventListener('change', this.getSafeAreaInsets); + SafeArea.removeEventListener('safeAreaInsetsForRootViewDidChange', this.onSafeAreaInsetsForRootViewChange); EventEmitter.off('update_safe_area_view', this.getSafeAreaInsets); this.keyboardDidShowListener.remove(); this.keyboardDidHideListener.remove(); @@ -103,6 +102,15 @@ export default class SafeAreaIos extends PureComponent { } }; + onSafeAreaInsetsForRootViewChange = (result) => { + const {safeAreaInsets} = result; + + if (this.mounted && (DeviceTypes.IS_IPHONE_WITH_INSETS || mattermostManaged.hasSafeAreaInsets)) { + this.getStatusBarHeight(); + this.setState({safeAreaInsets}); + } + } + keyboardWillHide = () => { this.setState({keyboard: false}); }; diff --git a/app/components/safe_area_view/safe_area_view.ios.test.js b/app/components/safe_area_view/safe_area_view.ios.test.js new file mode 100644 index 000000000..85c64b572 --- /dev/null +++ b/app/components/safe_area_view/safe_area_view.ios.test.js @@ -0,0 +1,151 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import SafeArea from 'react-native-safe-area'; +import {shallow} from 'enzyme'; + +import Preferences from 'mattermost-redux/constants/preferences'; + +import {DeviceTypes} from 'app/constants'; +import mattermostManaged from 'app/mattermost_managed'; + +import SafeAreaIos from './safe_area_view.ios'; + +describe('SafeAreaIos', () => { + const baseProps = { + children: [], + keyboardOffset: 100, + useLandscapeMargin: false, + theme: Preferences.THEMES.default, + }; + + const TEST_INSETS_1 = { + safeAreaInsets: { + top: 123, + left: 123, + bottom: 123, + right: 123, + }, + }; + + const TEST_INSETS_2 = { + safeAreaInsets: { + top: 456, + left: 456, + bottom: 456, + right: 456, + }, + }; + + SafeArea.getSafeAreaInsetsForRootView = jest.fn().mockImplementation(() => { + return Promise.resolve(TEST_INSETS_1); + }); + + test('should match snapshot', () => { + const wrapper = shallow( + + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should get safe area insets on mount if DeviceTypes.IS_IPHONE_WITH_INSETS is true', async () => { + DeviceTypes.IS_IPHONE_WITH_INSETS = true; + mattermostManaged.hasSafeAreaInsets = false; + + const wrapper = shallow( + + ); + + expect(SafeArea.getSafeAreaInsetsForRootView).toHaveBeenCalled(); + await SafeArea.getSafeAreaInsetsForRootView(); + expect(wrapper.state().safeAreaInsets).toEqual(TEST_INSETS_1.safeAreaInsets); + }); + + test('should get safe area insets on mount if mattermostManaged.hasSafeAreaInsets is true', async () => { + DeviceTypes.IS_IPHONE_WITH_INSETS = false; + mattermostManaged.hasSafeAreaInsets = true; + + const wrapper = shallow( + + ); + + expect(SafeArea.getSafeAreaInsetsForRootView).toHaveBeenCalled(); + await SafeArea.getSafeAreaInsetsForRootView(); + expect(wrapper.state().safeAreaInsets).toEqual(TEST_INSETS_1.safeAreaInsets); + }); + + test('should not get safe area insets on mount if neither DeviceTypes.IS_IPHONE_WITH_INSET nor mattermostManaged.hasSafeAreaInsets is true', async () => { + DeviceTypes.IS_IPHONE_WITH_INSETS = false; + mattermostManaged.hasSafeAreaInsets = false; + + const wrapper = shallow( + + ); + + expect(SafeArea.getSafeAreaInsetsForRootView).not.toHaveBeenCalled(); + await SafeArea.getSafeAreaInsetsForRootView(); + expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_1.safeAreaInsets); + }); + + test('should set safe area insets on change if mounted and DeviceTypes.IS_IPHONE_WITH_INSETS is true', () => { + DeviceTypes.IS_IPHONE_WITH_INSETS = true; + mattermostManaged.hasSafeAreaInsets = false; + + const wrapper = shallow( + + ); + + expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets); + + const instance = wrapper.instance(); + instance.onSafeAreaInsetsForRootViewChange(TEST_INSETS_2); + expect(wrapper.state().safeAreaInsets).toEqual(TEST_INSETS_2.safeAreaInsets); + }); + + test('should set safe area insets on change if mounted and mattermostManaged.hasSafeAreaInsets is true', () => { + DeviceTypes.IS_IPHONE_WITH_INSETS = false; + mattermostManaged.hasSafeAreaInsets = true; + + const wrapper = shallow( + + ); + + expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets); + + const instance = wrapper.instance(); + instance.onSafeAreaInsetsForRootViewChange(TEST_INSETS_2); + expect(wrapper.state().safeAreaInsets).toEqual(TEST_INSETS_2.safeAreaInsets); + }); + + test('should not set safe area insets on change if mounted and neither DeviceTypes.IS_IPHONE_WITH_INSETS nor mattermostManaged.hasSafeAreaInsets is true', () => { + DeviceTypes.IS_IPHONE_WITH_INSETS = false; + mattermostManaged.hasSafeAreaInsets = false; + + const wrapper = shallow( + + ); + + expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets); + + const instance = wrapper.instance(); + instance.onSafeAreaInsetsForRootViewChange(TEST_INSETS_2); + expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets); + }); + + test('should set safe area insets on change not mounted', () => { + DeviceTypes.IS_IPHONE_WITH_INSETS = true; + mattermostManaged.hasSafeAreaInsets = true; + + const wrapper = shallow( + + ); + + expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets); + + const instance = wrapper.instance(); + instance.mounted = false; + instance.onSafeAreaInsetsForRootViewChange(TEST_INSETS_2); + expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets); + }); +}); diff --git a/app/components/search_bar/search_box.js b/app/components/search_bar/search_box.js index dbf390bfb..1f2f12180 100644 --- a/app/components/search_bar/search_box.js +++ b/app/components/search_bar/search_box.js @@ -132,9 +132,9 @@ export default class Search extends Component { this.shadowHeight = this.props.shadowOffsetHeightCollapsed; } - componentWillReceiveProps(nextProps) { - if (this.props.value !== nextProps.value) { - if (nextProps.value) { + componentDidUpdate(prevProps) { + if (this.props.value !== prevProps.value) { + if (this.props.value) { this.iconDeleteAnimated = new Animated.Value(1); } else { this.iconDeleteAnimated = new Animated.Value(0); diff --git a/app/push_notifications/push_notifications.android.js b/app/push_notifications/push_notifications.android.js index 97ac14965..a937c8f84 100644 --- a/app/push_notifications/push_notifications.android.js +++ b/app/push_notifications/push_notifications.android.js @@ -94,9 +94,8 @@ class PushNotification { NotificationsAndroid.cancelAllLocalNotifications(); } - setApplicationIconBadgeNumber(number) { - const count = number < 0 ? 0 : number; - NotificationsAndroid.setBadgesCount(count); + setApplicationIconBadgeNumber() { + // Not supported for Android } getNotification() { @@ -116,7 +115,6 @@ class PushNotification { } clearNotifications = () => { - this.setApplicationIconBadgeNumber(0); this.cancelAllLocalNotifications(); // TODO: Only cancel the local notifications that belong to this server } } diff --git a/app/push_notifications/push_notifications.ios.js b/app/push_notifications/push_notifications.ios.js index e9ac2e9a8..7036cca99 100644 --- a/app/push_notifications/push_notifications.ios.js +++ b/app/push_notifications/push_notifications.ios.js @@ -2,15 +2,23 @@ // See LICENSE.txt for license information. import {AppState} from 'react-native'; -import NotificationsIOS, {NotificationAction, NotificationCategory} from 'react-native-notifications'; +import NotificationsIOS, { + NotificationAction, + NotificationCategory, + DEVICE_REMOTE_NOTIFICATIONS_REGISTERED_EVENT, + DEVICE_NOTIFICATION_RECEIVED_FOREGROUND_EVENT, + DEVICE_NOTIFICATION_OPENED_EVENT, +} from 'react-native-notifications'; import {getBadgeCount} from 'app/selectors/views'; import ephemeralStore from 'app/store/ephemeral_store'; +import {getCurrentLocale} from 'app/selectors/i18n'; +import {getLocalizedMessage} from 'app/i18n'; +import {t} from 'app/utils/i18n'; const CATEGORY = 'CAN_REPLY'; const REPLY_ACTION = 'REPLY_ACTION'; -let replyCategory; const replies = new Set(); class PushNotification { @@ -21,24 +29,9 @@ class PushNotification { this.onReply = null; this.reduxStore = null; - NotificationsIOS.addEventListener('remoteNotificationsRegistered', this.onRemoteNotificationsRegistered); - NotificationsIOS.addEventListener('notificationReceivedForeground', this.onNotificationReceivedForeground); - NotificationsIOS.addEventListener('notificationReceivedBackground', this.onNotificationReceivedBackground); - NotificationsIOS.addEventListener('notificationOpened', this.onNotificationOpened); - - const replyAction = new NotificationAction({ - activationMode: 'background', - title: 'Reply', - behavior: 'textInput', - authenticationRequired: true, - identifier: REPLY_ACTION, - }, this.handleReply); - - replyCategory = new NotificationCategory({ - identifier: CATEGORY, - actions: [replyAction], - context: 'default', - }); + NotificationsIOS.addEventListener(DEVICE_REMOTE_NOTIFICATIONS_REGISTERED_EVENT, this.onRemoteNotificationsRegistered); + NotificationsIOS.addEventListener(DEVICE_NOTIFICATION_RECEIVED_FOREGROUND_EVENT, this.onNotificationReceivedForeground); + NotificationsIOS.addEventListener(DEVICE_NOTIFICATION_OPENED_EVENT, this.onNotificationOpened); } handleNotification = (data, foreground, userInteraction) => { @@ -55,18 +48,14 @@ class PushNotification { } }; - handleReply = (action, completed) => { - if (action.identifier === REPLY_ACTION) { - const data = action.notification.getData(); - const text = action.text; - const badge = parseInt(action.notification._badge, 10) - 1; //eslint-disable-line no-underscore-dangle + handleReply = (notification, text, completion) => { + const data = notification.getData(); - if (this.onReply && !replies.has(action.completionKey)) { - replies.add(action.completionKey); - this.onReply(data, text, badge, completed); - } + if (this.onReply && !replies.has(data.identifier)) { + replies.add(data.identifier); + this.onReply(data, text, completion); } else { - completed(); + completion(); } }; @@ -76,9 +65,39 @@ class PushNotification { this.onNotification = options.onNotification; this.onReply = options.onReply; - this.requestPermissions([replyCategory]); + this.requestNotificationReplyPermissions(); + } - NotificationsIOS.consumeBackgroundQueue(); + requestNotificationReplyPermissions = () => { + const replyCategory = this.createReplyCategory(); + this.requestPermissions([replyCategory]); + } + + createReplyCategory = () => { + const {getState} = this.reduxStore; + const state = getState(); + const locale = getCurrentLocale(state); + + const replyTitle = getLocalizedMessage(locale, t('mobile.push_notification_reply.title')); + const replyButton = getLocalizedMessage(locale, t('mobile.push_notification_reply.button')); + const replyPlaceholder = getLocalizedMessage(locale, t('mobile.push_notification_reply.placeholder')); + + const replyAction = new NotificationAction({ + activationMode: 'background', + title: replyTitle, + textInput: { + buttonTitle: replyButton, + placeholder: replyPlaceholder, + }, + authenticationRequired: true, + identifier: REPLY_ACTION, + }); + + return new NotificationCategory({ + identifier: CATEGORY, + actions: [replyAction], + context: 'default', + }); } requestPermissions = (permissions) => { @@ -89,7 +108,7 @@ class PushNotification { if (notification.date) { const deviceNotification = { fireDate: notification.date.toISOString(), - alertBody: notification.message, + body: notification.message, alertAction: '', userInfo: notification.userInfo, }; @@ -100,7 +119,7 @@ class PushNotification { localNotification(notification) { this.deviceNotification = { - alertBody: notification.message, + body: notification.message, alertAction: '', userInfo: notification.userInfo, }; @@ -138,12 +157,17 @@ class PushNotification { this.handleNotification(info, true, false); }; - onNotificationOpened = (notification) => { - const info = { - ...notification.getData(), - message: notification.getMessage(), - }; - this.handleNotification(info, false, true); + onNotificationOpened = (notification, completion, action) => { + if (action.identifier === REPLY_ACTION) { + this.handleReply(notification, action.text, completion); + } else { + const info = { + ...notification.getData(), + message: notification.getMessage(), + }; + this.handleNotification(info, false, true); + completion(); + } }; onRemoteNotificationsRegistered = (deviceToken) => { @@ -165,6 +189,10 @@ class PushNotification { this.deviceNotification = null; } + getDeliveredNotifications(callback) { + NotificationsIOS.getDeliveredNotifications(callback); + } + clearChannelNotifications(channelId) { NotificationsIOS.getDeliveredNotifications((notifications) => { const ids = []; @@ -180,7 +208,7 @@ class PushNotification { for (let i = 0; i < notifications.length; i++) { const notification = notifications[i]; - if (notification.userInfo.channel_id === channelId) { + if (notification.channel_id === channelId) { ids.push(notification.identifier); } } diff --git a/app/push_notifications/push_notifications.ios.test.js b/app/push_notifications/push_notifications.ios.test.js index 51d249cb1..62ee2e6c5 100644 --- a/app/push_notifications/push_notifications.ios.test.js +++ b/app/push_notifications/push_notifications.ios.test.js @@ -39,25 +39,25 @@ describe('PushNotification', () => { // Three channel1 delivered notifications { identifier: 'channel1-1', - userInfo: {channel_id: channel1ID}, + channel_id: channel1ID, }, { identifier: 'channel1-2', - userInfo: {channel_id: channel1ID}, + channel_id: channel1ID, }, { identifier: 'channel1-3', - userInfo: {channel_id: channel1ID}, + channel_id: channel1ID, }, // Two channel2 delivered notifications { identifier: 'channel2-1', - userInfo: {channel_id: channel2ID}, + channel_id: channel2ID, }, { identifier: 'channel2-2', - userInfo: {channel_id: channel2ID}, + channel_id: channel2ID, }, ]; NotificationsIOS.setDeliveredNotifications(deliveredNotifications); @@ -73,8 +73,8 @@ describe('PushNotification', () => { await NotificationsIOS.getDeliveredNotifications(async (deliveredNotifs) => { expect(deliveredNotifs.length).toBe(2); - const channel1DeliveredNotifications = deliveredNotifs.filter((n) => n.userInfo.channel_id === channel1ID); - const channel2DeliveredNotifications = deliveredNotifs.filter((n) => n.userInfo.channel_id === channel2ID); + const channel1DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel1ID); + const channel2DeliveredNotifications = deliveredNotifs.filter((n) => n.channel_id === channel2ID); expect(channel1DeliveredNotifications.length).toBe(0); expect(channel2DeliveredNotifications.length).toBe(2); }); diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index 76e6227bc..65a0ae1d0 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -20,6 +20,7 @@ import SafeAreaView from 'app/components/safe_area_view'; import SettingsSidebar from 'app/components/sidebars/settings'; import {preventDoubleTap} from 'app/utils/tap'; +import {setNavigatorStyles} from 'app/utils/theme'; import PushNotifications from 'app/push_notifications'; import EphemeralStore from 'app/store/ephemeral_store'; import tracker from 'app/utils/time_tracker'; @@ -27,7 +28,6 @@ import telemetry from 'app/telemetry'; import { goToScreen, showModalOverCurrentContext, - mergeNavigationOptions, } from 'app/actions/navigation'; import LocalConfig from 'assets/config'; @@ -69,12 +69,7 @@ export default class ChannelBase extends PureComponent { this.postTextbox = React.createRef(); this.keyboardTracker = React.createRef(); - const options = { - layout: { - backgroundColor: props.theme.centerChannelBg, - }, - }; - mergeNavigationOptions(props.componentId, options); + setNavigatorStyles(props.componentId, props.theme); if (LocalConfig.EnableMobileClientUpgrade && !ClientUpgradeListener) { ClientUpgradeListener = require('app/components/client_upgrade_listener').default; @@ -113,12 +108,9 @@ export default class ChannelBase extends PureComponent { componentWillReceiveProps(nextProps) { if (this.props.theme !== nextProps.theme) { - const options = { - layout: { - backgroundColor: nextProps.theme.centerChannelBg, - }, - }; - mergeNavigationOptions(this.props.componentId, options); + EphemeralStore.allNavigationComponentIds.forEach((componentId) => { + setNavigatorStyles(componentId, nextProps.theme); + }); } if (nextProps.currentTeamId && this.props.currentTeamId !== nextProps.currentTeamId) { @@ -322,6 +314,12 @@ export default class ChannelBase extends PureComponent { ); } + + render() { + // Overriden in channel.android.js and channel.ios.js + // but defined here for channel_base.test.js + return; // eslint-disable-line no-useless-return + } } export const style = StyleSheet.create({ diff --git a/app/screens/channel/channel_base.test.js b/app/screens/channel/channel_base.test.js new file mode 100644 index 000000000..7dc29d405 --- /dev/null +++ b/app/screens/channel/channel_base.test.js @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from 'mattermost-redux/constants/preferences'; + +import EphemeralStore from 'app/store/ephemeral_store'; +import * as NavigationActions from 'app/actions/navigation'; + +import ChannelBase from './channel_base'; + +jest.mock('react-intl'); + +describe('ChannelBase', () => { + const componentIds = ['component-1', 'component-2', 'component-3']; + const baseProps = { + actions: { + loadChannelsIfNecessary: jest.fn(), + loadProfilesAndTeamMembersForDMSidebar: jest.fn(), + selectDefaultTeam: jest.fn(), + selectInitialChannel: jest.fn(), + recordLoadTime: jest.fn(), + getChannelStats: jest.fn(), + }, + componentId: componentIds[0], + theme: Preferences.THEMES.default, + }; + const optionsForTheme = (theme) => { + return { + topBar: { + backButton: { + color: theme.sidebarHeaderTextColor, + }, + background: { + color: theme.sidebarHeaderBg, + }, + title: { + color: theme.sidebarHeaderTextColor, + }, + leftButtonColor: theme.sidebarHeaderTextColor, + rightButtonColor: theme.sidebarHeaderTextColor, + }, + layout: { + backgroundColor: theme.centerChannelBg, + }, + }; + }; + + test('should call mergeNavigationOptions on all navigation components when theme changes', () => { + const mergeNavigationOptions = jest.spyOn(NavigationActions, 'mergeNavigationOptions'); + + componentIds.forEach((componentId) => { + EphemeralStore.addNavigationComponentId(componentId); + }); + + const wrapper = shallow( + , + ); + + const themeOptions = optionsForTheme(Preferences.THEMES.default); + expect(mergeNavigationOptions.mock.calls).toEqual([ + [componentIds[0], themeOptions], + ]); + mergeNavigationOptions.mockClear(); + + wrapper.setProps({theme: Preferences.THEMES.mattermostDark}); + + const newThemeOptions = optionsForTheme(Preferences.THEMES.mattermostDark); + expect(mergeNavigationOptions.mock.calls).toEqual([ + [componentIds[2], newThemeOptions], + [componentIds[1], newThemeOptions], + [componentIds[0], newThemeOptions], + ]); + }); +}); diff --git a/app/screens/channel/channel_nav_bar/channel_drawer_button/__snapshots__/channel_drawer_button.test.js.snap b/app/screens/channel/channel_nav_bar/channel_drawer_button/__snapshots__/channel_drawer_button.test.js.snap index 90b7fd840..1f0dd3675 100644 --- a/app/screens/channel/channel_nav_bar/channel_drawer_button/__snapshots__/channel_drawer_button.test.js.snap +++ b/app/screens/channel/channel_nav_bar/channel_drawer_button/__snapshots__/channel_drawer_button.test.js.snap @@ -2,6 +2,10 @@ exports[`ChannelDrawerButton should match, full snapshot 1`] = ` diff --git a/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js index cd9eef22a..499cf4a22 100644 --- a/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js +++ b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js @@ -2,13 +2,13 @@ // See LICENSE.txt for license information. import React from 'react'; -import {shallow} from 'enzyme'; import NotificationsIOS from 'react-native-notifications'; import Preferences from 'mattermost-redux/constants/preferences'; import Badge from 'app/components/badge'; import PushNotification from 'app/push_notifications/push_notifications.ios'; +import {shallowWithIntl} from 'test/intl-test-helper'; import ChannelDrawerButton from './channel_drawer_button'; @@ -48,7 +48,7 @@ describe('ChannelDrawerButton', () => { afterEach(() => NotificationsIOS.setBadgesCount(0)); test('should match, full snapshot', () => { - const wrapper = shallow( + const wrapper = shallowWithIntl( ); @@ -69,7 +69,7 @@ describe('ChannelDrawerButton', () => { badgeCount: 0, }; - shallow( + shallowWithIntl( ); expect(setApplicationIconBadgeNumber).not.toBeCalled(); @@ -83,7 +83,7 @@ describe('ChannelDrawerButton', () => { badgeCount: 1, }; - shallow( + shallowWithIntl( ); expect(setApplicationIconBadgeNumber).toHaveBeenCalledTimes(1); @@ -97,7 +97,7 @@ describe('ChannelDrawerButton', () => { badgeCount: 0, }; - const wrapper = shallow( + const wrapper = shallowWithIntl( ); NotificationsIOS.getBadgesCount((count) => expect(count).toBe(0)); @@ -115,7 +115,7 @@ describe('ChannelDrawerButton', () => { badgeCount: 0, }; - const wrapper = shallow( + const wrapper = shallowWithIntl( ); wrapper.setProps({badgeCount: 2}); diff --git a/app/screens/permalink/index.js b/app/screens/permalink/index.js index d97525ec9..275d4df3e 100644 --- a/app/screens/permalink/index.js +++ b/app/screens/permalink/index.js @@ -20,8 +20,6 @@ import {isLandscape} from 'app/selectors/device'; import { handleSelectChannel, loadThreadIfNecessary, - setChannelDisplayName, - setChannelLoading, } from 'app/actions/views/channel'; import {handleTeamChange} from 'app/actions/views/select_team'; @@ -73,8 +71,6 @@ function mapDispatchToProps(dispatch) { joinChannel, loadThreadIfNecessary, selectPost, - setChannelDisplayName, - setChannelLoading, }, dispatch), }; } diff --git a/app/screens/permalink/permalink.js b/app/screens/permalink/permalink.js index 7d6bb3aeb..8577461f1 100644 --- a/app/screens/permalink/permalink.js +++ b/app/screens/permalink/permalink.js @@ -65,8 +65,6 @@ export default class Permalink extends PureComponent { joinChannel: PropTypes.func.isRequired, loadThreadIfNecessary: PropTypes.func.isRequired, selectPost: PropTypes.func.isRequired, - setChannelDisplayName: PropTypes.func.isRequired, - setChannelLoading: PropTypes.func.isRequired, }).isRequired, channelId: PropTypes.string, channelIsArchived: PropTypes.bool, @@ -221,24 +219,22 @@ export default class Permalink extends PureComponent { }; handlePress = () => { - const {channelIdState, channelNameState} = this.state; + const {channelIdState} = this.state; if (this.refs.view) { this.refs.view.growOut().then(() => { - this.jumpToChannel(channelIdState, channelNameState); + this.jumpToChannel(channelIdState); }); } }; - jumpToChannel = async (channelId, channelDisplayName) => { + jumpToChannel = async (channelId) => { if (channelId) { const {actions, channelTeamId, currentTeamId, onClose} = this.props; const currentChannelId = this.props.channelId; const { handleSelectChannel, handleTeamChange, - setChannelLoading, - setChannelDisplayName, } = actions; actions.selectPost(''); @@ -263,8 +259,6 @@ export default class Permalink extends PureComponent { handleTeamChange(channelTeamId); } - setChannelLoading(channelId !== currentChannelId); - setChannelDisplayName(channelDisplayName); handleSelectChannel(channelId); } }; diff --git a/app/screens/permalink/permalink.test.js b/app/screens/permalink/permalink.test.js index 0d8edbe6b..d104633b9 100644 --- a/app/screens/permalink/permalink.test.js +++ b/app/screens/permalink/permalink.test.js @@ -20,8 +20,6 @@ describe('Permalink', () => { joinChannel: jest.fn(), loadThreadIfNecessary: jest.fn(), selectPost: jest.fn(), - setChannelDisplayName: jest.fn(), - setChannelLoading: jest.fn(), }; const baseProps = { diff --git a/app/screens/settings/notification_settings_auto_responder/notification_settings_auto_responder.js b/app/screens/settings/notification_settings_auto_responder/notification_settings_auto_responder.js index 9609bd855..f9ecde3a2 100644 --- a/app/screens/settings/notification_settings_auto_responder/notification_settings_auto_responder.js +++ b/app/screens/settings/notification_settings_auto_responder/notification_settings_auto_responder.js @@ -44,6 +44,8 @@ export default class NotificationSettingsAutoResponder extends PureComponent { const {intl} = this.context; const notifyProps = getNotificationProps(currentUser); + this.autoresponderRef = React.createRef(); + const autoResponderDefault = intl.formatMessage({ id: 'mobile.notification_settings.auto_responder.default_message', defaultMessage: 'Hello, I am out of office and unable to respond to messages.', @@ -65,6 +67,16 @@ export default class NotificationSettingsAutoResponder extends PureComponent { this.saveUserNotifyProps(); } + componentDidMount() { + setTimeout(() => { + requestAnimationFrame(() => { + if (this.autoresponderRef.current) { + this.autoresponderRef.current.focus(); + } + }); + }, 500); + } + saveUserNotifyProps = () => { this.props.onBack({ ...this.state, @@ -126,8 +138,7 @@ export default class NotificationSettingsAutoResponder extends PureComponent { > ({ + key, + ...Preferences.THEMES[key], +})); describe('Theme', () => { const baseProps = { diff --git a/app/utils/push_notifications.js b/app/utils/push_notifications.js index 84e726294..29e4dc274 100644 --- a/app/utils/push_notifications.js +++ b/app/utils/push_notifications.js @@ -102,7 +102,7 @@ class PushNotificationUtils { } }; - onPushNotificationReply = async (data, text, badge, completed) => { + onPushNotificationReply = async (data, text, completion) => { const {dispatch, getState} = this.store; const state = getState(); const credentials = await getAppCredentials(); // TODO Change to handle multiple servers @@ -144,23 +144,21 @@ class PushNotificationUtils { channel_id: data.channel_id, }, }); - completed(); + completion(); return; } - if (badge >= 0) { - PushNotifications.setApplicationIconBadgeNumber(badge); - } - - dispatch(markChannelViewedAndRead(data.channel_id)); this.replyNotificationData = null; - completed(); + + PushNotifications.getDeliveredNotifications((notifications) => { + PushNotifications.setApplicationIconBadgeNumber(notifications.length); + completion(); + }); } else { this.replyNotificationData = { data, text, - badge, - completed, + completion, }; } }; diff --git a/app/utils/push_notifications.test.js b/app/utils/push_notifications.test.js index 065f8cd6c..f61eca441 100644 --- a/app/utils/push_notifications.test.js +++ b/app/utils/push_notifications.test.js @@ -32,7 +32,6 @@ jest.mock('react-native-notifications', () => { addEventListener: jest.fn(), NotificationAction: jest.fn(), NotificationCategory: jest.fn(), - consumeBackgroundQueue: jest.fn(), localNotification: jest.fn(), }; }); @@ -49,9 +48,8 @@ describe('PushNotifications', () => { const channelID = 'channel-id'; const data = {channel_id: channelID}; const text = 'text'; - const badge = 1; - const completed = () => {}; - await PushNotificationUtils.onPushNotificationReply(data, text, badge, completed); + const completion = () => {}; + await PushNotificationUtils.onPushNotificationReply(data, text, completion); const storeActions = store.getActions(); const receivedPost = storeActions.some((action) => action.type === PostTypes.RECEIVED_POST); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2f53fee79..78cb6a8b8 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -384,6 +384,9 @@ "mobile.post.retry": "Refresh", "mobile.posts_view.moreMsg": "More New Messages Above", "mobile.privacy_link": "Privacy Policy", + "mobile.push_notification_reply.button": "Send", + "mobile.push_notification_reply.placeholder": "Write a reply...", + "mobile.push_notification_reply.title": "Reply", "mobile.reaction_header.all_emojis": "All", "mobile.recent_mentions.empty_description": "Messages containing your username and other words that trigger mentions will appear here.", "mobile.recent_mentions.empty_title": "No Recent Mentions", @@ -487,6 +490,8 @@ "msg_typing.areTyping": "{users} and {last} are typing...", "msg_typing.isTyping": "{user} is typing...", "navbar_dropdown.logout": "Logout", + "navbar.channel_drawer.button": "Channels and teams", + "navbar.channel_drawer.hint": "Opens the channels and teams drawer", "navbar.leave": "Leave Channel", "password_form.title": "Password Reset", "password_send.checkInbox": "Please check your inbox.", diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 329082953..8593d2e92 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -11,69 +11,73 @@ configured = false is_build_pr = false # Executes before anything else use to setup the script -before_all do |lane, options| - if lane.to_s == 'build_pr' - pr = options[:pr] - UI.success("Building #{pr}") - ENV['BRANCH_TO_BUILD'] = pr - is_build_pr = true +before_all do + is_build_pr = ENV['BUILD_PR'] == 'true' + if is_build_pr && ENV['CIRCLECI'] == 'true' + ENV['BRANCH_TO_BUILD'] = ENV['CIRCLE_BRANCH'] end - # Raises an error is git is not clean - if ENV['COMMIT_CHANGES_TO_GIT'] == 'true' - ensure_git_status_clean - end + UI.success("Building for release #{ENV['BUILD_FOR_RELEASE']}") - # Block to ensure we are on the right branch - branch = ENV['BRANCH_TO_BUILD'] || ENV['GIT_BRANCH'] - begin - ensure_git_branch( - branch: branch - ) - rescue - sh "git checkout #{branch}" - UI.success("Using branch \"#{branch}\"") - end + if ENV['CIRCLECI'] != 'true' + # Raises an error is git is not clean + if ENV['COMMIT_CHANGES_TO_GIT'] == 'true' + ensure_git_status_clean + end - # If we are going to commit changes to git create a separate branch - # so no changes are done in the branch that is being built - if ENV['COMMIT_CHANGES_TO_GIT'] == 'true' - local_branch = ENV['GIT_LOCAL_BRANCH'] || 'build' - sh "git checkout -b #{local_branch}" - UI.success("Creating branch \"#{local_branch}\"") + # Block to ensure we are on the right branch + branch = ENV['BRANCH_TO_BUILD'] || ENV['GIT_BRANCH'] + begin + ensure_git_branch( + branch: branch + ) + rescue + sh "git checkout #{branch}" + UI.success("Using branch \"#{branch}\"") + end + + # If we are going to commit changes to git create a separate branch + # so no changes are done in the branch that is being built + if ENV['COMMIT_CHANGES_TO_GIT'] == 'true' + local_branch = ENV['GIT_LOCAL_BRANCH'] || 'build' + sh "git checkout -b #{local_branch}" + UI.success("Creating branch \"#{local_branch}\"") + end end end after_all do |lane| - if lane.to_s == 'build' - submit_to_store - end - - if ENV['RESET_GIT_BRANCH'] == 'true' - branch = ENV['BRANCH_TO_BUILD'] || 'master' - package_id = ENV['MAIN_APP_IDENTIFIER'] || 'com.mattermost.rnbeta' - beta_dir = '../android/app/src/main/java/com/mattermost/rnbeta' - release_dir = "../android/app/src/main/java/#{package_id.gsub '.', '/'}" - - if beta_dir != release_dir - sh "rm -rf #{release_dir}" + if ENV['CIRCLECI'] != 'true' + if lane.to_s == 'build' + submit_to_store end - reset_git_repo( - force: true, - skip_clean: true - ) - sh "git checkout #{branch}" + if ENV['RESET_GIT_BRANCH'] == 'true' + branch = ENV['BRANCH_TO_BUILD'] || 'master' + package_id = ENV['MAIN_APP_IDENTIFIER'] || 'com.mattermost.rnbeta' + beta_dir = '../android/app/src/main/java/com/mattermost/rnbeta' + release_dir = "../android/app/src/main/java/#{package_id.gsub '.', '/'}" - if ENV['COMMIT_CHANGES_TO_GIT'] == 'true' - local_branch = ENV['GIT_LOCAL_BRANCH'] || 'build' - sh "git branch -D #{local_branch}" - UI.success("Deleted working branch \"#{local_branch}\"") - if lane.to_s == 'build_pr' - sh 'git checkout master' - ## Remove the branch for the PR - sh "git branch -D #{branch}" - UI.success("Deleted PR branch \"#{branch}\"") + if beta_dir != release_dir + sh "rm -rf #{release_dir}" + end + + reset_git_repo( + force: true, + skip_clean: true + ) + sh "git checkout #{branch}" + + if ENV['COMMIT_CHANGES_TO_GIT'] == 'true' + local_branch = ENV['GIT_LOCAL_BRANCH'] || 'build' + sh "git branch -D #{local_branch}" + UI.success("Deleted working branch \"#{local_branch}\"") + if lane.to_s == 'build_pr' + sh 'git checkout master' + ## Remove the branch for the PR + sh "git branch -D #{branch}" + UI.success("Deleted PR branch \"#{branch}\"") + end end end end @@ -196,20 +200,110 @@ lane :unsigned do unsigned end -desc 'Build the app using a PR so QA can test' -lane :build_pr do - configure +desc 'Upload file to s3' +lane :upload_file_to_s3 do |options| + os_type = options[:os_type] + file = options[:file] + file_plist = "" - # Build the android app - self.runner.current_platform = :android - build + if file.nil? || file.empty? + app_name = ENV['APP_NAME'] || 'Mattermost Beta' + filename = app_name.gsub(" ", "_") - # Build the ios app - self.runner.current_platform = :ios - build + if os_type == 'Android' + file = "#{filename}.apk" + elsif os_type == 'iOS' + file = "#{filename}.ipa" + file_plist = "#{filename}.plist" + end + end + + build_folder_path = Dir[File.expand_path('..')].first + file_path = "#{build_folder_path}/#{file}" + + unless ENV['AWS_BUCKET_NAME'].nil? || ENV['AWS_BUCKET_NAME'].empty? || ENV['AWS_REGION'].nil? || ENV['AWS_REGION'].empty? || file_path.nil? + s3_region = ENV['AWS_REGION'] + s3_bucket = ENV['AWS_BUCKET_NAME'] + s3_folder = '' + + version_number = '' + build_number = '' + + if is_build_pr + s3_folder = "#{ENV['AWS_FOLDER_NAME']}/#{ENV['BRANCH_TO_BUILD']}" + else + if os_type == 'Android' + version_number = android_get_version_name(gradle_file: './android/app/build.gradle') + build_number = android_get_version_code(gradle_file: './android/app/build.gradle') + elsif os_type == 'iOS' + version_number = get_version_number(xcodeproj: './ios/Mattermost.xcodeproj', target: 'Mattermost') + build_number = get_build_number(xcodeproj: './ios/Mattermost.xcodeproj') + end + + s3_folder = "#{ENV['AWS_FOLDER_NAME']}/#{version_number}/#{build_number}" + end + + s3 = Aws::S3::Resource.new(region: s3_region) + file_obj = s3.bucket(s3_bucket).object("#{s3_folder}/#{file}") + file_obj.upload_file("#{file_path}") + + if is_build_pr + if os_type == 'Android' + install_url = "https://#{s3_bucket}/#{s3_folder}/#{file}" + elsif os_type == 'iOS' + current_build_number = get_build_number(xcodeproj: './ios/Mattermost.xcodeproj') + plist_template = File.read('plist.erb') + plist_body = ERB.new(plist_template).result(binding) + + plist_obj = s3.bucket(s3_bucket).object("#{s3_folder}/#{file_plist}") + plist_obj.put(body: plist_body) + + install_url = "itms-services://?action=download-manifest&url=https://#{s3_bucket}/#{s3_folder}/#{file_plist}" + end + + qa_build_message({ + :os_type => os_type, + :install_url => install_url + }) + end + + public_link = "https://#{s3_bucket}/#{s3_folder}/#{file}" + if file == 'Mattermost-simulator-x86_64.app.zip' + pretext = '#### New iOS build for VM/Simulator' + msg = "Download link: #{public_link}" + send_message_to_mattermost({ + :version_number => version_number, + :build_number => build_number, + :pretext => pretext, + :title => '', + :thumb_url => 'https://support.apple.com/library/content/dam/edam/applecare/images/en_US/iOS/move-to-ios-icon.png', + :msg => msg, + :default_payloads => [], + :success => true, + }) + end + + UI.success("S3 bucket @#{s3_bucket}, object @#{s3_folder}/#{file}") + UI.success("S3 public path: #{public_link}") + end end platform :ios do + before_all do + if ENV['CIRCLECI'] == 'true' + setup_circle_ci + end + end + + desc 'Get iOS adhoc profiles' + lane :adhoc do + if ENV['MATCH_TYPE'] != 'adhoc' && !ENV['MATCH_PASSWORD'].nil? && !ENV['MATCH_PASSWORD'].empty? + match( + type: 'adhoc' + ) + end + end + desc 'Build iOS app' lane :build do unless configured @@ -321,7 +415,7 @@ platform :ios do ) # Sync the provisioning profiles using match - if ENV['SYNC_PROVISIONING_PROFILES'] == 'true' + if ENV['SYNC_PROVISIONING_PROFILES'] == 'true' && !ENV['MATCH_PASSWORD'].nil? && !ENV['MATCH_PASSWORD'].empty? match(type: ENV['MATCH_TYPE'] || 'adhoc') end end @@ -333,6 +427,14 @@ platform :ios do end end + lane :deploy do |options| + ipa_path = options[:ipa] + + unless ipa_path.nil? + submit_to_testflight(ipa_path) + end + end + error do |lane, exception| version = get_version_number(xcodeproj: './ios/Mattermost.xcodeproj', target: 'Mattermost') build_number = get_build_number(xcodeproj: './ios/Mattermost.xcodeproj') @@ -348,12 +450,37 @@ platform :ios do }) end + def setup_code_signing + disable_automatic_code_signing(path: './ios/Mattermost.xcodeproj') + + ENV['MATCH_APP_IDENTIFIER'].split(',').each do |id| + target = 'Mattermost' + if id.include? 'NotificationService' + target = 'NotificationService' + elsif id.include? 'MattermostShare' + target = 'MattermostShare' + end + + profile = "sigh_#{id}_#{ENV['MATCH_TYPE']}" + + update_project_provisioning( + xcodeproj: './ios/Mattermost.xcodeproj', + profile: ENV["#{profile}_profile-path"], # optional if you use sigh + target_filter: ".*#{target}$", # matches name or type of a target + build_configuration: 'Release', + code_signing_identity: 'iPhone Distribution' # optionally specify the codesigning identity + ) + end + end + def build_ios() app_name = ENV['APP_NAME'] || 'Mattermost Beta' app_name_sub = app_name.gsub(" ", "_") config_mode = ENV['BUILD_FOR_RELEASE'] == 'true' ? 'Release' : 'Debug' method = ENV['IOS_BUILD_EXPORT_METHOD'].nil? || ENV['IOS_BUILD_EXPORT_METHOD'].empty? ? 'ad-hoc' : ENV['IOS_BUILD_EXPORT_METHOD'] + setup_code_signing + gym( clean: true, scheme: 'Mattermost', @@ -362,6 +489,7 @@ platform :ios do export_method: method, skip_profile_detection: true, output_name: "#{app_name_sub}.ipa", + export_xcargs: "-allowProvisioningUpdates", export_options: { signingStyle: 'manual', iCloudContainerEnvironment: 'Production' @@ -483,6 +611,14 @@ platform :android do end end + lane :deploy do |options| + apk_path = options[:apk] + + unless apk_path.nil? + submit_to_google_play(apk_path) + end + end + error do |lane, exception| build_number = android_get_version_code( gradle_file: './android/app/build.gradle' @@ -587,83 +723,102 @@ def send_message_to_mattermost(options) end end +def submit_to_testflight(ipa_path) + app_name = ENV['APP_NAME'] || 'Mattermost Beta' + app_name_sub = app_name.gsub(" ", "_") + + if(File.file?(ipa_path)) + UI.success("ipa file #{ipa_path}") + upload_to_tesflight( + ipa: ipa_path + ) + else + UI.user_error! "ipa file does not exist #{ipa_path}" + return + end + + version_number = get_version_number(xcodeproj: './ios/Mattermost.xcodeproj', target: 'Mattermost') + build_number = get_build_number(xcodeproj: './ios/Mattermost.xcodeproj') + s3_folder = "#{ENV['AWS_FOLDER_NAME']}/#{version_number}/#{build_number}" + public_link = "https://#{ENV['AWS_BUCKET_NAME']}/#{s3_folder}/#{app_name_sub}.ipa" + + + # Send a build message to Mattermost + pretext = '#### New iOS released ready to be published' + msg = "Release has been cut and is on TestFlight ready to be published.\nDownload link: #{public_link}" + + if ENV['BETA_BUILD'] == 'true' + pretext = '#### New iOS beta published to TestFlight' + msg = "Sign up as a beta tester [here](https://testflight.apple.com/join/Q7Rx7K9P).\nDownload link: #{public_link}" + end + + send_message_to_mattermost({ + :version_number => version_number, + :build_number => build_number, + :pretext => pretext, + :title => '', + :thumb_url => 'https://support.apple.com/library/content/dam/edam/applecare/images/en_US/iOS/move-to-ios-icon.png', + :msg => msg, + :default_payloads => [], + :success => true, + }) +end + +def submit_to_google_play(apk_path) + app_name = ENV['APP_NAME'] || 'Mattermost Beta' + app_name_sub = app_name.gsub(" ", "_") + + if(File.file?(apk_path)) + UI.success("apk file #{apk_path}") + unless ENV['SUPPLY_JSON_KEY'].nil? || ENV['SUPPLY_JSON_KEY'].empty? + supply( + track: ENV['SUPPLY_TRACK'] || 'alpha', + apk: apk_path + ) + end + else + UI.user_error! "apk file does not exist #{apk_path}" + return + end + + version_number = android_get_version_name(gradle_file: './android/app/build.gradle') + build_number = android_get_version_code(gradle_file: './android/app/build.gradle') + s3_folder = "#{ENV['AWS_FOLDER_NAME']}/#{version_number}/#{build_number}" + public_link = "https://#{ENV['AWS_BUCKET_NAME']}/#{s3_folder}/#{app_name_sub}.apk" + + # Send a build message to Mattermost + pretext = '#### New Android released ready to be published' + msg = "Release has been cut and is on the Beta track ready to be published.\nDownload link: #{public_link}" + + if ENV['BETA_BUILD'] == 'true' + pretext = '#### New Android beta published to Google Play' + msg = "Sign up as a beta tester [here](https://play.google.com/apps/testing/com.mattermost.rnbeta).\nDownload link: #{public_link}" + end + + send_message_to_mattermost({ + :version_number => version_number, + :build_number => build_number, + :pretext => pretext, + :title => '', + :thumb_url => 'https://lh3.ggpht.com/XL0CrI8skkxnboGct-duyg-bZ_MxJDTrjczyjdU8OP2PM1dmj7SP4jL1K8JQeMIB3AM=w300', + :msg => msg, + :default_payloads => [], + :success => true, + }) +end + def submit_to_store apk_path = lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH] ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH] - app_name = ENV['APP_NAME'] || 'Mattermost Beta' - app_name_sub = app_name.gsub(" ", "_") - - s3_region = ENV['AWS_REGION'] - s3_bucket = ENV['AWS_BUCKET_NAME'] - # Submit to Google Play if required if !apk_path.nil? && ENV['SUBMIT_ANDROID_TO_GOOGLE_PLAY'] == 'true' - UI.success("apk file #{apk_path}") - - supply( - track: ENV['SUPPLY_TRACK'] || 'alpha', - apk: apk_path - ) - - version_number = android_get_version_name(gradle_file: './android/app/build.gradle') - build_number = android_get_version_code(gradle_file: './android/app/build.gradle') - - s3_folder = "#{ENV['AWS_FOLDER_NAME']}/#{version_number}/#{build_number}" - public_link = "https://#{s3_bucket}/#{s3_folder}/#{app_name_sub}.apk" - - # Send a build message to Mattermost - pretext = '#### New Android released ready to be published' - msg = "Release has been cut and is on the Beta track ready to be published.\nDownload link: #{public_link}" - - if ENV['BETA_BUILD'] == 'true' - pretext = '#### New Android beta published to Google Play' - msg = "Sign up as a beta tester [here](https://play.google.com/apps/testing/com.mattermost.rnbeta).\nDownload link: #{public_link}" - end - - send_message_to_mattermost({ - :version_number => version_number, - :build_number => build_number, - :pretext => pretext, - :title => '', - :thumb_url => 'https://lh3.ggpht.com/XL0CrI8skkxnboGct-duyg-bZ_MxJDTrjczyjdU8OP2PM1dmj7SP4jL1K8JQeMIB3AM=w300', - :msg => msg, - :default_payloads => [], - :success => true, - }) + submit_to_google_play(apk_path) end # Submit to TestFlight if required if !ipa_path.nil? && ENV['SUBMIT_IOS_TO_TESTFLIGHT'] == 'true' - UI.success("ipa file #{ipa_path}") - - pilot - - version_number = get_version_number(xcodeproj: './ios/Mattermost.xcodeproj', target: 'Mattermost') - build_number = get_build_number(xcodeproj: './ios/Mattermost.xcodeproj') - - s3_folder = "#{ENV['AWS_FOLDER_NAME']}/#{version_number}/#{build_number}" - public_link = "https://#{s3_bucket}/#{s3_folder}/#{app_name_sub}.ipa" - - # Send a build message to Mattermost - pretext = '#### New iOS released ready to be published' - msg = "Release has been cut and is on TestFlight ready to be published.\nDownload link: #{public_link}" - - if ENV['BETA_BUILD'] == 'true' - pretext = '#### New iOS beta published to TestFlight' - msg = "Sign up as a beta tester [here](https://testflight.apple.com/join/Q7Rx7K9P).\nDownload link: #{public_link}" - end - - send_message_to_mattermost({ - :version_number => version_number, - :build_number => build_number, - :pretext => pretext, - :title => '', - :thumb_url => 'https://support.apple.com/library/content/dam/edam/applecare/images/en_US/iOS/move-to-ios-icon.png', - :msg => msg, - :default_payloads => [], - :success => true, - }) + submit_to_testflight(ipa_path) end end @@ -686,92 +841,3 @@ def qa_build_message(options) UI.success("PR Built for #{os_type}: #{install_url}") end end - -desc 'Upload file to s3' -lane :upload_file_to_s3 do |options| - os_type = options[:os_type] - file = options[:file] - file_plist = "" - - if file.nil? || file.empty? - app_name = ENV['APP_NAME'] || 'Mattermost Beta' - filename = app_name.gsub(" ", "_") - - if os_type == 'Android' - file = "#{filename}.apk" - elsif os_type == 'iOS' - file = "#{filename}.ipa" - file_plist = "#{filename}.plist" - end - end - - - build_folder_path = Dir[File.expand_path('..')].first - file_path = "#{build_folder_path}/#{file}" - - unless ENV['AWS_BUCKET_NAME'].nil? || ENV['AWS_BUCKET_NAME'].empty? || ENV['AWS_REGION'].nil? || ENV['AWS_REGION'].empty? || file_path.nil? - s3_region = ENV['AWS_REGION'] - s3_bucket = ENV['AWS_BUCKET_NAME'] - s3_folder = '' - - version_number = '' - build_number = '' - - if is_build_pr - s3_folder = "#{ENV['AWS_FOLDER_NAME']}/#{ENV['BRANCH_TO_BUILD']}" - else - if os_type == 'Android' - version_number = android_get_version_name(gradle_file: './android/app/build.gradle') - build_number = android_get_version_code(gradle_file: './android/app/build.gradle') - elsif os_type == 'iOS' - version_number = get_version_number(xcodeproj: './ios/Mattermost.xcodeproj', target: 'Mattermost') - build_number = get_build_number(xcodeproj: './ios/Mattermost.xcodeproj') - end - - s3_folder = "#{ENV['AWS_FOLDER_NAME']}/#{version_number}/#{build_number}" - end - - s3 = Aws::S3::Resource.new(region: s3_region) - file_obj = s3.bucket(s3_bucket).object("#{s3_folder}/#{file}") - file_obj.upload_file("#{file_path}") - - if is_build_pr - if os_type == 'Android' - install_url = "https://#{s3_bucket}/#{s3_folder}/#{file}" - elsif os_type == 'iOS' - current_build_number = get_build_number(xcodeproj: './ios/Mattermost.xcodeproj') - plist_template = File.read('plist.erb') - plist_body = ERB.new(plist_template).result(binding) - - plist_obj = s3.bucket(s3_bucket).object("#{s3_folder}/#{file_plist}") - plist_obj.put(body: plist_body) - - install_url = "itms-services://?action=download-manifest&url=https://#{s3_bucket}/#{s3_folder}/#{file_plist}" - end - - qa_build_message({ - :os_type => os_type, - :install_url => install_url - }) - end - - public_link = "https://#{s3_bucket}/#{s3_folder}/#{file}" - if file == 'Mattermost-simulator-x86_64.app.zip' - pretext = '#### New iOS build for VM/Simulator' - msg = "Download link: #{public_link}" - send_message_to_mattermost({ - :version_number => version_number, - :build_number => build_number, - :pretext => pretext, - :title => '', - :thumb_url => 'https://support.apple.com/library/content/dam/edam/applecare/images/en_US/iOS/move-to-ios-icon.png', - :msg => msg, - :default_payloads => [], - :success => true, - }) - end - - UI.success("S3 bucket @#{s3_bucket}, object @#{s3_folder}/#{file}") - UI.success("S3 public path: #{public_link}") - end -end diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index 1b10393ed..a33a2f7b8 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -2637,21 +2637,17 @@ buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${SRCROOT}/Pods/Target Support Files/Pods-Mattermost/Pods-Mattermost-resources.sh", + "${PODS_ROOT}/Target Support Files/Pods-Mattermost/Pods-Mattermost-resources.sh", "${PODS_ROOT}/YoutubePlayer-in-WKWebView/WKYTPlayerView/WKYTPlayerView.bundle", ); name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - ); outputPaths = ( "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WKYTPlayerView.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Mattermost/Pods-Mattermost-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Mattermost/Pods-Mattermost-resources.sh\"\n"; showEnvVarsInLog = 0; }; AE4769B235D14E6C9C64EA78 /* Upload Debug Symbols to Sentry */ = { diff --git a/ios/Mattermost/AppDelegate.m b/ios/Mattermost/AppDelegate.m index ff2d2f0d6..357d84cb7 100644 --- a/ios/Mattermost/AppDelegate.m +++ b/ios/Mattermost/AppDelegate.m @@ -26,9 +26,9 @@ @implementation AppDelegate -NSString* const NotificationMessageAction = @"message"; -NSString* const NotificationClearAction = @"clear"; -NSString* const NotificationUpdateBadgeAction = @"update_badge"; +NSString* const NOTIFICATION_MESSAGE_ACTION = @"message"; +NSString* const NOTIFICATION_CLEAR_ACTION = @"clear"; +NSString* const NOTIFICATION_UPDATE_BADGE_ACTION = @"update_badge"; -(void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler { os_log(OS_LOG_DEFAULT, "Mattermost will attach session from handleEventsForBackgroundURLSession!! identifier=%{public}@", identifier); @@ -60,18 +60,14 @@ NSString* const NotificationUpdateBadgeAction = @"update_badge"; [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error: nil]; + [RNNotifications startMonitorNotifications]; + os_log(OS_LOG_DEFAULT, "Mattermost started!!"); return YES; } -// Required to register for notifications -- (void)application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings -{ - [RNNotifications didRegisterUserNotificationSettings:notificationSettings]; -} - - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { [RNNotifications didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; @@ -81,7 +77,34 @@ NSString* const NotificationUpdateBadgeAction = @"update_badge"; [RNNotifications didFailToRegisterForRemoteNotificationsWithError:error]; } --(void)cleanNotificationsFromChannel:(NSString *)channelId andUpdateBadge:(BOOL)updateBadge { +// Required for the notification event. + +-(void)application:(UIApplication *)application didReceiveRemoteNotification:(nonnull NSDictionary *)userInfo fetchCompletionHandler:(nonnull void (^)(UIBackgroundFetchResult))completionHandler { + UIApplicationState state = [UIApplication sharedApplication].applicationState; + NSString* action = [userInfo objectForKey:@"type"]; + NSString* channelId = [userInfo objectForKey:@"channel_id"]; + NSString* ackId = [userInfo objectForKey:@"ack_id"]; + + if (action && [action isEqualToString: NOTIFICATION_CLEAR_ACTION]) { + // If received a notification that a channel was read, remove all notifications from that channel (only with app in foreground/background) + [self cleanNotificationsFromChannel:channelId]; + RuntimeUtils *utils = [[RuntimeUtils alloc] init]; + [[UploadSession shared] notificationReceiptWithNotificationId:ackId receivedAt:round([[NSDate date] timeIntervalSince1970] * 1000.0) type:action]; + [utils delayWithSeconds:0.2 closure:^(void) { + // This is to notify the NotificationCenter that something has changed. + completionHandler(UIBackgroundFetchResultNewData); + }]; + + return; + } else if (state == UIApplicationStateInactive) { + // When the notification is opened + [self cleanNotificationsFromChannel:channelId]; + } + + completionHandler(UIBackgroundFetchResultNoData); +} + +-(void)cleanNotificationsFromChannel:(NSString *)channelId { if ([UNUserNotificationCenter class]) { UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; [center getDeliveredNotificationsWithCompletionHandler:^(NSArray * _Nonnull notifications) { @@ -99,64 +122,10 @@ NSString* const NotificationUpdateBadgeAction = @"update_badge"; } [center removeDeliveredNotificationsWithIdentifiers:notificationIds]; - NSInteger removed = (NSInteger)[notificationIds count] + 1; - if (removed > 0 && updateBadge) { - NSInteger badge = [UIApplication sharedApplication].applicationIconBadgeNumber; - NSInteger count = badge - removed; - if (count > 0) { - [[UIApplication sharedApplication] setApplicationIconBadgeNumber:count]; - } else { - [[UIApplication sharedApplication] setApplicationIconBadgeNumber:0]; - } - } }]; } } -// Required for the notification event. --(void)application:(UIApplication *)application didReceiveRemoteNotification:(nonnull NSDictionary *)userInfo fetchCompletionHandler:(nonnull void (^)(UIBackgroundFetchResult))completionHandler { - UIApplicationState state = [UIApplication sharedApplication].applicationState; - NSString* action = [userInfo objectForKey:@"type"]; - NSString* channelId = [userInfo objectForKey:@"channel_id"]; - NSString* ackId = [userInfo objectForKey:@"ack_id"]; - - if (action && [action isEqualToString: NotificationClearAction]) { - // If received a notification that a channel was read, remove all notifications from that channel (only with app in foreground/background) - [self cleanNotificationsFromChannel:channelId andUpdateBadge:NO]; - RuntimeUtils *utils = [[RuntimeUtils alloc] init]; - [[UploadSession shared] notificationReceiptWithNotificationId:ackId receivedAt:round([[NSDate date] timeIntervalSince1970] * 1000.0) type:action]; - [utils delayWithSeconds:0.2 closure:^(void) { - // This is to notify the NotificationCenter that something has changed. - completionHandler(UIBackgroundFetchResultNewData); - }]; - - return; - } else if (state == UIApplicationStateInactive) { - // When the notification is opened - [self cleanNotificationsFromChannel:channelId andUpdateBadge:NO]; - } - - [RNNotifications didReceiveRemoteNotification:userInfo]; - completionHandler(UIBackgroundFetchResultNoData); -} - -// Required for the localNotification event. -- (void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification -{ - [RNNotifications didReceiveLocalNotification:notification]; -} - -// Required for the notification actions. -- (void)application:(UIApplication *)application handleActionWithIdentifier:(NSString *)identifier forLocalNotification:(UILocalNotification *)notification withResponseInfo:(NSDictionary *)responseInfo completionHandler:(void (^)())completionHandler -{ - [RNNotifications handleActionWithIdentifier:identifier forLocalNotification:notification withResponseInfo:responseInfo completionHandler:completionHandler]; -} - -- (void)application:(UIApplication *)application handleActionWithIdentifier:(NSString *)identifier forRemoteNotification:(NSDictionary *)userInfo withResponseInfo:(NSDictionary *)responseInfo completionHandler:(void (^)())completionHandler -{ - [RNNotifications handleActionWithIdentifier:identifier forRemoteNotification:userInfo withResponseInfo:responseInfo completionHandler:completionHandler]; -} - // Required for deeplinking - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dd0e6c594..a59d3a50e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -17,4 +17,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: e565d3af8eabb0e489b73c79433e140e30f8fa9c -COCOAPODS: 1.5.3 +COCOAPODS: 1.7.5 diff --git a/metro.config.js b/metro.config.js index 47579372c..585fb667c 100644 --- a/metro.config.js +++ b/metro.config.js @@ -40,6 +40,7 @@ const config = { }; }, }, + maxWorkers: 4, }; module.exports = config; diff --git a/package-lock.json b/package-lock.json index cd987ca87..9e06db7f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16739,8 +16739,9 @@ } }, "react-native-notifications": { - "version": "github:mattermost/react-native-notifications#1b7ec8513606b42237ab4674de9dacb4d1935e38", - "from": "github:mattermost/react-native-notifications#1b7ec8513606b42237ab4674de9dacb4d1935e38", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/react-native-notifications/-/react-native-notifications-2.0.6.tgz", + "integrity": "sha512-NFx5ADlqfQYTFkKWvd/GxM8rxKf1lSWJZJY0jbydAOZAuhnKFR/CsH7Mpx6T+9pY5Z3rvu7UzBtVn9LTBx0jYg==", "requires": { "core-js": "^1.0.0", "uuid": "^2.0.3" @@ -17586,7 +17587,6 @@ "version": "git+https://github.com/enahum/redux-offline.git#885024de96b6ec73650c340c8928066585c413df", "from": "git+https://github.com/enahum/redux-offline.git#885024de96b6ec73650c340c8928066585c413df", "requires": { - "@react-native-community/netinfo": "^4.1.3", "redux-persist": "^4.5.0" }, "dependencies": { diff --git a/package.json b/package.json index 7d687abf6..a1a1e8c98 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "react-native-linear-gradient": "2.5.4", "react-native-local-auth": "github:mattermost/react-native-local-auth#cc9ce2f468fbf7b431dfad3191a31aaa9227a6ab", "react-native-navigation": "2.21.1", - "react-native-notifications": "github:mattermost/react-native-notifications#1b7ec8513606b42237ab4674de9dacb4d1935e38", + "react-native-notifications": "2.0.6", "react-native-passcode-status": "1.1.1", "react-native-permissions": "1.1.1", "react-native-safe-area": "0.5.1", diff --git a/patches/react-native-notifications+2.0.6.patch b/patches/react-native-notifications+2.0.6.patch new file mode 100644 index 000000000..56857ce1d --- /dev/null +++ b/patches/react-native-notifications+2.0.6.patch @@ -0,0 +1,378 @@ +diff --git a/node_modules/react-native-notifications/android/app/src/main/AndroidManifest.xml b/node_modules/react-native-notifications/android/app/src/main/AndroidManifest.xml +index ffef75f..a4df210 100644 +--- a/node_modules/react-native-notifications/android/app/src/main/AndroidManifest.xml ++++ b/node_modules/react-native-notifications/android/app/src/main/AndroidManifest.xml +@@ -32,6 +32,8 @@ + ++ ++ + + + +diff --git a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java +index 8fb5f01..74d6138 100644 +--- a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java ++++ b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/RNNotificationsModule.java +@@ -103,12 +103,26 @@ public class RNNotificationsModule extends ReactContextBaseJavaModule implements + pushNotification.onPostRequest(notificationId); + } + ++ @ReactMethod ++ public void scheduleLocalNotification(ReadableMap notificationPropsMap, int notificationId) { ++ Log.d(LOGTAG, "Native method invocation: scheduleLocalNotification"); ++ final Bundle notificationProps = Arguments.toBundle(notificationPropsMap); ++ final IPushNotification pushNotification = PushNotification.get(getReactApplicationContext().getApplicationContext(), notificationProps); ++ pushNotification.onScheduleRequest(notificationId); ++ } ++ + @ReactMethod + public void cancelLocalNotification(int notificationId) { + IPushNotificationsDrawer notificationsDrawer = PushNotificationsDrawer.get(getReactApplicationContext().getApplicationContext()); + notificationsDrawer.onNotificationClearRequest(notificationId); + } + ++ @ReactMethod ++ public void cancelAllLocalNotifications() { ++ IPushNotificationsDrawer notificationDrawer = PushNotificationsDrawer.get(getReactApplicationContext().getApplicationContext()); ++ notificationDrawer.onCancelAllLocalNotifications(); ++ } ++ + @ReactMethod + public void isRegisteredForRemoteNotifications(Promise promise) { + boolean hasPermission = NotificationManagerCompat.from(getReactApplicationContext()).areNotificationsEnabled(); +diff --git a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/helpers/ScheduleNotificationHelper.java b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/helpers/ScheduleNotificationHelper.java +new file mode 100644 +index 0000000..c35076d +--- /dev/null ++++ b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/helpers/ScheduleNotificationHelper.java +@@ -0,0 +1,90 @@ ++package com.wix.reactnativenotifications.core.helpers; ++ ++import android.app.AlarmManager; ++import android.os.Build; ++import android.os.Bundle; ++import android.content.Context; ++import android.content.Intent; ++import android.app.PendingIntent; ++import android.content.SharedPreferences; ++import android.util.Log; ++ ++import com.wix.reactnativenotifications.core.notification.PushNotificationProps; ++import com.wix.reactnativenotifications.core.notification.PushNotificationPublisher; ++ ++import static com.wix.reactnativenotifications.Defs.LOGTAG; ++ ++public class ScheduleNotificationHelper { ++ public static ScheduleNotificationHelper sInstance; ++ public static final String PREFERENCES_KEY = "rn_push_notification"; ++ static final String NOTIFICATION_ID = "notificationId"; ++ ++ private final SharedPreferences scheduledNotificationsPersistence; ++ protected final Context mContext; ++ ++ private ScheduleNotificationHelper(Context context) { ++ this.mContext = context; ++ this.scheduledNotificationsPersistence = context.getSharedPreferences(ScheduleNotificationHelper.PREFERENCES_KEY, Context.MODE_PRIVATE); ++ } ++ ++ public static ScheduleNotificationHelper getInstance(Context context) { ++ if (sInstance == null) { ++ sInstance = new ScheduleNotificationHelper(context); ++ } ++ return sInstance; ++ } ++ ++ public PendingIntent createPendingNotificationIntent(Bundle bundle) { ++ Integer notificationId = Integer.valueOf(bundle.getString("id")); ++ Intent notificationIntent = new Intent(mContext, PushNotificationPublisher.class); ++ notificationIntent.putExtra(ScheduleNotificationHelper.NOTIFICATION_ID, notificationId); ++ notificationIntent.putExtras(bundle); ++ return PendingIntent.getBroadcast(mContext, notificationId, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); ++ } ++ ++ public void schedulePendingNotificationIntent(PendingIntent intent, long fireDate) { ++ AlarmManager alarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); ++ ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { ++ alarmManager.setExact(AlarmManager.RTC_WAKEUP, fireDate, intent); ++ } else { ++ alarmManager.set(AlarmManager.RTC_WAKEUP, fireDate, intent); ++ } ++ } ++ ++ public void cancelScheduledNotificationIntent(PendingIntent intent) { ++ AlarmManager alarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); ++ alarmManager.cancel(intent); ++ } ++ ++ public boolean savePreferences(String notificationId, PushNotificationProps notificationProps) { ++ SharedPreferences.Editor editor = scheduledNotificationsPersistence.edit(); ++ editor.putString(notificationId, notificationProps.toString()); ++ commit(editor); ++ ++ return scheduledNotificationsPersistence.contains(notificationId); ++ } ++ ++ public void removePreference(String notificationId) { ++ if (scheduledNotificationsPersistence.contains(notificationId)) { ++ // remove it from local storage ++ SharedPreferences.Editor editor = scheduledNotificationsPersistence.edit(); ++ editor.remove(notificationId); ++ commit(editor); ++ } else { ++ Log.w(LOGTAG, "Unable to find notification " + notificationId); ++ } ++ } ++ ++ public java.util.Set getPreferencesKeys() { ++ return scheduledNotificationsPersistence.getAll().keySet(); ++ } ++ ++ private static void commit(SharedPreferences.Editor editor) { ++ if (Build.VERSION.SDK_INT < 9) { ++ editor.commit(); ++ } else { ++ editor.apply(); ++ } ++ } ++} +\ No newline at end of file +diff --git a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/IPushNotification.java b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/IPushNotification.java +index 0d70024..47b962e 100644 +--- a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/IPushNotification.java ++++ b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/IPushNotification.java +@@ -26,5 +26,20 @@ public interface IPushNotification { + */ + int onPostRequest(Integer notificationId); + ++ /** ++ * Handle a request to schedule this notification. ++ * ++ * @param notificationId The specific ID to associated with the notification. ++ */ ++ void onScheduleRequest(Integer notificationId); ++ ++ /** ++ * Handle a request to post this scheduled notification. ++ * ++ * @param notificationId The specific ID to associated with the notification. ++ * @return The ID assigned to the notification. ++ */ ++ int onPostScheduledRequest(Integer notificationId); ++ + PushNotificationProps asProps(); + } +diff --git a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java +index 5e4e3d2..871e157 100644 +--- a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java ++++ b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotification.java +@@ -1,5 +1,6 @@ + package com.wix.reactnativenotifications.core.notification; + ++import android.app.AlarmManager; + import android.app.Notification; + import android.app.NotificationChannel; + import android.app.NotificationManager; +@@ -20,7 +21,9 @@ import com.wix.reactnativenotifications.core.InitialNotificationHolder; + import com.wix.reactnativenotifications.core.JsIOHelper; + import com.wix.reactnativenotifications.core.NotificationIntentAdapter; + import com.wix.reactnativenotifications.core.ProxyService; ++import com.wix.reactnativenotifications.core.helpers.ScheduleNotificationHelper; + ++import static com.wix.reactnativenotifications.Defs.LOGTAG; + import static com.wix.reactnativenotifications.Defs.NOTIFICATION_OPENED_EVENT_NAME; + import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME; + import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_FOREGROUND_EVENT_NAME; +@@ -80,6 +83,41 @@ public class PushNotification implements IPushNotification { + return postNotification(notificationId); + } + ++ @Override ++ public void onScheduleRequest(Integer notificationId) { ++ Bundle bundle = mNotificationProps.asBundle(); ++ ++ if (bundle.getString("message") == null) { ++ Log.e(LOGTAG, "No message specified for the scheduled notification"); ++ return; ++ } ++ ++ double date = bundle.getDouble("fireDate"); ++ if (date == 0) { ++ Log.e(LOGTAG, "No date specified for the scheduled notification"); ++ return; ++ } ++ ++ ScheduleNotificationHelper helper = ScheduleNotificationHelper.getInstance(mContext); ++ String notificationIdStr = Integer.toString(notificationId); ++ boolean isSaved = helper.savePreferences(notificationIdStr, mNotificationProps); ++ if (!isSaved) { ++ Log.e(LOGTAG, "Failed to save preference for notificationId " + notificationIdStr); ++ } ++ ++ PendingIntent pendingIntent = helper.createPendingNotificationIntent(bundle); ++ long fireDate = (long) date; ++ helper.schedulePendingNotificationIntent(pendingIntent, fireDate); ++ } ++ ++ @Override ++ public int onPostScheduledRequest(Integer notificationId) { ++ ScheduleNotificationHelper helper = ScheduleNotificationHelper.getInstance(mContext); ++ helper.removePreference(String.valueOf(notificationId)); ++ ++ return postNotification(notificationId); ++ } ++ + @Override + public PushNotificationProps asProps() { + return mNotificationProps.copy(); +@@ -140,11 +178,12 @@ public class PushNotification implements IPushNotification { + } + + protected Notification buildNotification(PendingIntent intent) { +- return getNotificationBuilder(intent).build(); ++ Notification.Builder builder = getNotificationBuilder(intent); ++ Notification notification = builder.build(); ++ return notification; + } + + protected Notification.Builder getNotificationBuilder(PendingIntent intent) { +- + String CHANNEL_ID = "channel_01"; + String CHANNEL_NAME = "Channel Name"; + +diff --git a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotificationPublisher.java b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotificationPublisher.java +new file mode 100644 +index 0000000..5b64593 +--- /dev/null ++++ b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notification/PushNotificationPublisher.java +@@ -0,0 +1,27 @@ ++package com.wix.reactnativenotifications.core.notification; ++ ++import android.app.Application; ++import android.content.BroadcastReceiver; ++import android.content.Context; ++import android.content.Intent; ++import android.util.Log; ++ ++import static com.wix.reactnativenotifications.Defs.LOGTAG; ++ ++public class PushNotificationPublisher extends BroadcastReceiver { ++ final static String NOTIFICATION_ID = "notificationId"; ++ ++ @Override ++ public void onReceive(Context context, Intent intent) { ++ Log.d(LOGTAG, "Received scheduled notification intent"); ++ int notificationId = intent.getIntExtra(NOTIFICATION_ID, 0); ++ long currentTime = System.currentTimeMillis(); ++ ++ Application applicationContext = (Application) context.getApplicationContext(); ++ final IPushNotification pushNotification = PushNotification.get(applicationContext, intent.getExtras()); ++ ++ Log.i(LOGTAG, "PushNotificationPublisher: Prepare To Publish: " + notificationId + ", Now Time: " + currentTime); ++ ++ pushNotification.onPostScheduledRequest(notificationId); ++ } ++} +\ No newline at end of file +diff --git a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/IPushNotificationsDrawer.java b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/IPushNotificationsDrawer.java +index 3be3dc1..7027958 100644 +--- a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/IPushNotificationsDrawer.java ++++ b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/IPushNotificationsDrawer.java +@@ -9,4 +9,5 @@ public interface IPushNotificationsDrawer { + + void onNotificationOpened(); + void onNotificationClearRequest(int id); ++ void onCancelAllLocalNotifications(); + } +diff --git a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java +index 7b320e1..d95535b 100644 +--- a/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java ++++ b/node_modules/react-native-notifications/android/app/src/main/java/com/wix/reactnativenotifications/core/notificationdrawer/PushNotificationsDrawer.java +@@ -2,10 +2,16 @@ package com.wix.reactnativenotifications.core.notificationdrawer; + + import android.app.Activity; + import android.app.NotificationManager; ++import android.app.PendingIntent; + import android.content.Context; ++import android.os.Bundle; ++import android.util.Log; + + import com.wix.reactnativenotifications.core.AppLaunchHelper; + import com.wix.reactnativenotifications.core.InitialNotificationHolder; ++import com.wix.reactnativenotifications.core.helpers.ScheduleNotificationHelper; ++ ++import static com.wix.reactnativenotifications.Defs.LOGTAG; + + public class PushNotificationsDrawer implements IPushNotificationsDrawer { + +@@ -60,8 +66,41 @@ public class PushNotificationsDrawer implements IPushNotificationsDrawer { + notificationManager.cancel(id); + } + ++ @Override ++ public void onCancelAllLocalNotifications() { ++ clearAll(); ++ cancelAllScheduledNotifications(); ++ } ++ + protected void clearAll() { + final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancelAll(); + } ++ ++ protected void cancelAllScheduledNotifications() { ++ Log.i(LOGTAG, "Cancelling all scheduled notifications"); ++ ScheduleNotificationHelper helper = ScheduleNotificationHelper.getInstance(mContext); ++ ++ for (String notificationId : helper.getPreferencesKeys()) { ++ cancelScheduledNotification(notificationId); ++ } ++ } ++ ++ protected void cancelScheduledNotification(String notificationId) { ++ Log.i(LOGTAG, "Cancelling scheduled notification: " + notificationId); ++ ++ ScheduleNotificationHelper helper = ScheduleNotificationHelper.getInstance(mContext); ++ ++ // Remove it from the alarm manger schedule ++ Bundle bundle = new Bundle(); ++ bundle.putString("id", notificationId); ++ PendingIntent pendingIntent = helper.createPendingNotificationIntent(bundle); ++ helper.cancelScheduledNotificationIntent(pendingIntent); ++ ++ helper.removePreference(notificationId); ++ ++ // Remove it from the notification center ++ final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); ++ notificationManager.cancel(Integer.parseInt(notificationId)); ++ } + } +diff --git a/node_modules/react-native-notifications/lib/src/index.android.js b/node_modules/react-native-notifications/lib/src/index.android.js +index 51376bf..a5d9540 100644 +--- a/node_modules/react-native-notifications/lib/src/index.android.js ++++ b/node_modules/react-native-notifications/lib/src/index.android.js +@@ -67,9 +67,22 @@ export class NotificationsAndroid { + return id; + } + ++ static scheduleLocalNotification(notification: Object) { ++ const id = Math.random() * 100000000 | 0; // Bitwise-OR forces value onto a 32bit limit ++ if (!notification.hasOwnProperty('id')) { ++ notification.id = id.toString(); ++ } ++ RNNotifications.scheduleLocalNotification(notification, id); ++ return id; ++ } ++ + static cancelLocalNotification(id) { + RNNotifications.cancelLocalNotification(id); + } ++ ++ static cancelAllLocalNotifications() { ++ RNNotifications.cancelAllLocalNotifications(); ++ } + } + + export class PendingNotifications {