Compare commits

...

6 commits

Author SHA1 Message Date
Mattermost Build
850a3d5d2a
Update NOTICE.txt (#8012) (#8013)
Some checks failed
github-release / test (push) Has been cancelled
github-release / build-ios-unsigned (push) Has been cancelled
github-release / build-android-unsigned (push) Has been cancelled
github-release / release (push) Has been cancelled
(cherry picked from commit f17d4872ce)

Co-authored-by: Amy Blais <29708087+amyblais@users.noreply.github.com>
2024-06-12 17:31:19 +02:00
Mattermost Build
78c01a74f7
Add performance metrics to the app (#7953) (#8008)
* Add performance metrics to the app

* add batcher and improve handling

* Add tests

* Fix test

* Address feedback

* Address feedback

* Address feedback

* update podfile

(cherry picked from commit 5f01f9e9af)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2024-06-12 09:51:38 +02:00
Mattermost Build
1594d5e77f
T271: Prevent access to Android components if they do not need external communication (#7990) (#8005)
(cherry picked from commit daa38c5fd1)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2024-06-12 01:05:44 +08:00
Mattermost Build
38d3c7d1d1
fix android push notifications when app is closed (#7989) (#8003)
(cherry picked from commit e5ce796770)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2024-06-11 05:29:14 +08:00
Mattermost Build
e347e0a96c
replace swizzle method_exchangeImplementations with method_setImplementation (#7991) (#8002)
(cherry picked from commit f0e80aa7eb)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2024-06-11 05:28:59 +08:00
Mattermost Build
272895d37f
Fix priority footer on tablets (#7966) (#8001)
(cherry picked from commit b966e1f356)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
2024-06-10 17:31:46 +02:00
49 changed files with 1832 additions and 299 deletions

View file

@ -2399,6 +2399,40 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
## react-native-performance
This product contains 'react-native-performance' by Joel Arvidsson.
Toolchain to measure and monitor the performance of your React Native app in development, pipeline and in production.
* HOMEPAGE:
* https://github.com/oblador/react-native-performance
* LICENSE: MIT
The MIT License (MIT)
Copyright (c) 2019 - present Joel Arvidsson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---

View file

@ -1,14 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission-sdk-23 android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:ignore="SelectedPhotoAccess" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:ignore="SelectedPhotoAccess" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:ignore="SelectedPhotoAccess" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
@ -34,6 +40,8 @@
<application
android:name=".MainApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
@ -49,7 +57,6 @@
android:resource="@xml/app_restrictions" />
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
@ -84,14 +91,12 @@
android:name="com.reactnativenavigation.controllers.NavigationActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:resizeableActivity="true"
android:exported="true"
android:exported="false"
/>
<activity
android:name="com.mattermost.share.ShareActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize"
android:label="@string/app_name"
android:screenOrientation="portrait"
android:theme="@style/AppTheme"
android:taskAffinity="com.mattermost.share"
android:exported="true"
@ -109,7 +114,7 @@
<service
android:name="com.voximplant.foregroundservice.VIForegroundService"
android:foregroundServiceType="microphone"
android:exported="true"
android:exported="false"
/>
</application>
</manifest>

View file

@ -4,29 +4,27 @@ import android.content.Context
import android.os.Bundle
import android.util.Log
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.mattermost.helpers.database_extension.*
import com.mattermost.helpers.push_notification.*
import kotlinx.coroutines.*
import com.mattermost.helpers.database_extension.getDatabaseForServer
import com.mattermost.helpers.database_extension.saveToDatabase
import com.mattermost.helpers.push_notification.addToDefaultCategoryIfNeeded
import com.mattermost.helpers.push_notification.fetchMyChannel
import com.mattermost.helpers.push_notification.fetchMyTeamCategories
import com.mattermost.helpers.push_notification.fetchNeededUsers
import com.mattermost.helpers.push_notification.fetchPosts
import com.mattermost.helpers.push_notification.fetchTeamIfNeeded
import com.mattermost.helpers.push_notification.fetchThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class PushNotificationDataHelper(private val context: Context) {
private var coroutineScope = CoroutineScope(Dispatchers.Default)
fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
var result: Bundle? = null
val job = coroutineScope.launch(Dispatchers.Default) {
result = PushNotificationDataRunnable.start(context, initialData, isReactInit)
suspend fun fetchAndStoreDataForPushNotification(initialData: Bundle, isReactInit: Boolean): Bundle? {
return withContext(Dispatchers.Default) {
PushNotificationDataRunnable.start(context, initialData, isReactInit)
}
runBlocking {
job.join()
}
return result
}
}
@ -37,8 +35,8 @@ class PushNotificationDataRunnable {
private val mutex = Mutex()
suspend fun start(context: Context, initialData: Bundle, isReactInit: Boolean): Bundle? {
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
mutex.withLock {
// for more info see: https://blog.danlew.net/2020/01/28/coroutines-and-java-synchronization-dont-mix/
val serverUrl: String = initialData.getString("server_url") ?: return null
val db = dbHelper.getDatabaseForServer(context, serverUrl)
var result: Bundle? = null
@ -50,8 +48,9 @@ class PushNotificationDataRunnable {
val postId = initialData.getString("post_id")
val rootId = initialData.getString("root_id")
val isCRTEnabled = initialData.getString("is_crt_enabled") == "true"
val ackId = initialData.getString("ack_id")
Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId")
Log.i("ReactNative", "Start fetching notification data in server=$serverUrl for channel=$channelId and ack=$ackId")
val receivingThreads = isCRTEnabled && !rootId.isNullOrEmpty()
val notificationData = Arguments.createMap()
@ -89,7 +88,7 @@ class PushNotificationDataRunnable {
getThreadList(notificationThread, postData?.getArray("threads"))?.let {
val threadsArray = Arguments.createArray()
for(item in it) {
for (item in it) {
threadsArray.pushMap(item)
}
notificationData.putArray("threads", threadsArray)
@ -105,7 +104,7 @@ class PushNotificationDataRunnable {
dbHelper.saveToDatabase(db, notificationData, teamId, channelId, receivingThreads)
}
Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId")
Log.i("ReactNative", "Done processing push notification=$serverUrl for channel=$channelId and ack=$ackId")
}
} catch (e: Exception) {
e.printStackTrace()

View file

@ -1,6 +1,7 @@
package com.mattermost.helpers.database_extension
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.text.TextUtils
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableMap
@ -8,8 +9,7 @@ import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.QueryArgs
import com.mattermost.helpers.mapCursor
import com.nozbe.watermelondb.WMDatabase
import java.util.*
import kotlin.Exception
import java.util.Arrays
internal fun DatabaseHelper.saveToDatabase(db: WMDatabase, data: ReadableMap, teamId: String?, channelId: String?, receivingThreads: Boolean) {
db.transaction {
@ -57,7 +57,7 @@ fun DatabaseHelper.getDatabaseForServer(context: Context?, serverUrl: String): W
if (cursor.count == 1) {
cursor.moveToFirst()
val databasePath = String.format("file://%s", cursor.getString(0))
return WMDatabase.getInstance(databasePath, context!!)
return WMDatabase.buildDatabase(databasePath, context!!, SQLiteDatabase.CREATE_IF_NECESSARY)
}
}
} catch (e: Exception) {
@ -73,7 +73,7 @@ fun DatabaseHelper.getDeviceToken(): String? {
defaultDatabase!!.rawQuery(query, arrayOf("deviceToken")).use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
return cursor.getString(0);
return cursor.getString(0)
}
}
} catch (e: Exception) {

View file

@ -1,183 +0,0 @@
package com.mattermost.rnbeta;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import java.util.Objects;
import com.facebook.react.bridge.ReadableMap;
import com.mattermost.helpers.CustomPushNotificationHelper;
import com.mattermost.helpers.DatabaseHelper;
import com.mattermost.helpers.Network;
import com.mattermost.helpers.NotificationHelper;
import com.mattermost.helpers.PushNotificationDataHelper;
import com.mattermost.helpers.ReadableMapUtils;
import com.mattermost.share.ShareModule;
import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
import com.wix.reactnativenotifications.core.notification.PushNotification;
import com.wix.reactnativenotifications.core.AppLaunchHelper;
import com.wix.reactnativenotifications.core.AppLifecycleFacade;
import com.wix.reactnativenotifications.core.JsIOHelper;
import static com.mattermost.helpers.database_extension.GeneralKt.*;
import static com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME;
public class CustomPushNotification extends PushNotification {
private final PushNotificationDataHelper dataHelper;
public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) {
super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper);
dataHelper = new PushNotificationDataHelper(context);
try {
Objects.requireNonNull(DatabaseHelper.Companion.getInstance()).init(context);
Network.init(context);
NotificationHelper.cleanNotificationPreferencesIfNeeded(context);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void onReceived() {
final Bundle initialData = mNotificationProps.asBundle();
final String type = initialData.getString("type");
final String ackId = initialData.getString("ack_id");
final String postId = initialData.getString("post_id");
final String channelId = initialData.getString("channel_id");
final String signature = initialData.getString("signature");
final boolean isIdLoaded = initialData.getString("id_loaded") != null && initialData.getString("id_loaded").equals("true");
int notificationId = NotificationHelper.getNotificationId(initialData);
String serverUrl = addServerUrlToBundle(initialData);
if (ackId != null && serverUrl != null) {
Bundle response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded);
if (isIdLoaded && response != null) {
Bundle current = mNotificationProps.asBundle();
if (!current.containsKey("server_url")) {
response.putString("server_url", serverUrl);
}
current.putAll(response);
mNotificationProps = createProps(current);
}
}
if (!CustomPushNotificationHelper.verifySignature(mContext, signature, serverUrl, ackId)) {
Log.i("Mattermost Notifications Signature verification", "Notification skipped because we could not verify it.");
return;
}
finishProcessingNotification(serverUrl, type, channelId, notificationId);
}
@Override
public void onOpened() {
if (mNotificationProps != null) {
digestNotification();
Bundle data = mNotificationProps.asBundle();
NotificationHelper.clearChannelOrThreadNotifications(mContext, data);
}
}
private void finishProcessingNotification(final String serverUrl, @NonNull final String type, final String channelId, final int notificationId) {
final boolean isReactInit = mAppLifecycleFacade.isReactInitialized();
switch (type) {
case CustomPushNotificationHelper.PUSH_TYPE_MESSAGE:
case CustomPushNotificationHelper.PUSH_TYPE_SESSION:
ShareModule shareModule = ShareModule.getInstance();
String currentActivityName = shareModule != null ? shareModule.getCurrentActivityName() : "";
Log.i("ReactNative", currentActivityName);
if (!mAppLifecycleFacade.isAppVisible() || !currentActivityName.equals("MainActivity")) {
boolean createSummary = type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE);
if (type.equals(CustomPushNotificationHelper.PUSH_TYPE_MESSAGE)) {
if (channelId != null) {
Bundle notificationBundle = mNotificationProps.asBundle();
if (serverUrl != null) {
// We will only fetch the data related to the notification on the native side
// as updating the data directly to the db removes the wal & shm files needed
// by watermelonDB, if the DB is updated while WDB is running it causes WDB to
// detect the database as malformed, thus the app stop working and a restart is required.
// Data will be fetch from within the JS context instead.
Bundle notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit);
if (notificationResult != null) {
notificationBundle.putBundle("data", notificationResult);
mNotificationProps = createProps(notificationBundle);
}
}
createSummary = NotificationHelper.addNotificationToPreferences(
mContext,
notificationId,
notificationBundle
);
}
}
buildNotification(notificationId, createSummary);
}
break;
case CustomPushNotificationHelper.PUSH_TYPE_CLEAR:
NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle());
break;
}
if (isReactInit) {
notifyReceivedToJS();
}
}
private void buildNotification(Integer notificationId, boolean createSummary) {
final PendingIntent pendingIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, mNotificationProps);
final Notification notification = buildNotification(pendingIntent);
if (createSummary) {
final Notification summary = getNotificationSummaryBuilder(pendingIntent).build();
super.postNotification(summary, notificationId + 1);
}
super.postNotification(notification, notificationId);
}
@Override
protected NotificationCompat.Builder getNotificationBuilder(PendingIntent intent) {
Bundle bundle = mNotificationProps.asBundle();
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, false);
}
protected NotificationCompat.Builder getNotificationSummaryBuilder(PendingIntent intent) {
Bundle bundle = mNotificationProps.asBundle();
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, true);
}
private void notifyReceivedToJS() {
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.getRunningReactContext());
}
private String addServerUrlToBundle(Bundle bundle) {
DatabaseHelper dbHelper = DatabaseHelper.Companion.getInstance();
String serverId = bundle.getString("server_id");
String serverUrl = null;
if (dbHelper != null) {
if (serverId == null) {
serverUrl = dbHelper.getOnlyServerUrl();
} else {
serverUrl = getServerUrlForIdentifier(dbHelper, serverId);
}
if (!TextUtils.isEmpty(serverUrl)) {
bundle.putString("server_url", serverUrl);
mNotificationProps = createProps(bundle);
}
}
return serverUrl;
}
}

View file

@ -0,0 +1,179 @@
package com.mattermost.rnbeta
import android.app.PendingIntent
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.core.app.NotificationCompat
import com.mattermost.helpers.CustomPushNotificationHelper
import com.mattermost.helpers.DatabaseHelper
import com.mattermost.helpers.Network
import com.mattermost.helpers.NotificationHelper
import com.mattermost.helpers.PushNotificationDataHelper
import com.mattermost.helpers.database_extension.getServerUrlForIdentifier
import com.mattermost.share.ShareModule
import com.wix.reactnativenotifications.Defs.NOTIFICATION_RECEIVED_EVENT_NAME
import com.wix.reactnativenotifications.core.AppLaunchHelper
import com.wix.reactnativenotifications.core.AppLifecycleFacade
import com.wix.reactnativenotifications.core.JsIOHelper
import com.wix.reactnativenotifications.core.NotificationIntentAdapter
import com.wix.reactnativenotifications.core.notification.PushNotification
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class CustomPushNotification(
context: Context,
bundle: Bundle,
appLifecycleFacade: AppLifecycleFacade,
appLaunchHelper: AppLaunchHelper,
jsIoHelper: JsIOHelper
) : PushNotification(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper) {
private val dataHelper = PushNotificationDataHelper(context)
init {
try {
DatabaseHelper.instance?.init(context)
Network.init(context)
NotificationHelper.cleanNotificationPreferencesIfNeeded(context)
} catch (e: Exception) {
e.printStackTrace()
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onReceived() {
val initialData = mNotificationProps.asBundle()
val type = initialData.getString("type")
val ackId = initialData.getString("ack_id")
val postId = initialData.getString("post_id")
val channelId = initialData.getString("channel_id")
val signature = initialData.getString("signature")
val isIdLoaded = initialData.getString("id_loaded") == "true"
val notificationId = NotificationHelper.getNotificationId(initialData)
val serverUrl = addServerUrlToBundle(initialData)
GlobalScope.launch {
try {
handlePushNotificationInCoroutine(serverUrl, type, channelId, ackId, isIdLoaded, notificationId, postId, signature)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private suspend fun handlePushNotificationInCoroutine(
serverUrl: String?,
type: String?,
channelId: String?,
ackId: String?,
isIdLoaded: Boolean,
notificationId: Int,
postId: String?,
signature: String?
) {
if (ackId != null && serverUrl != null) {
val response = ReceiptDelivery.send(ackId, serverUrl, postId, type, isIdLoaded)
if (isIdLoaded && response != null) {
val current = mNotificationProps.asBundle()
if (!current.containsKey("server_url")) {
response.putString("server_url", serverUrl)
}
current.putAll(response)
mNotificationProps = createProps(current)
}
}
if (!CustomPushNotificationHelper.verifySignature(mContext, signature, serverUrl, ackId)) {
Log.i("Mattermost Notifications Signature verification", "Notification skipped because we could not verify it.")
return
}
finishProcessingNotification(serverUrl, type, channelId, notificationId)
}
override fun onOpened() {
mNotificationProps?.let {
digestNotification()
NotificationHelper.clearChannelOrThreadNotifications(mContext, it.asBundle())
}
}
private suspend fun finishProcessingNotification(serverUrl: String?, type: String?, channelId: String?, notificationId: Int) {
val isReactInit = mAppLifecycleFacade.isReactInitialized()
when (type) {
CustomPushNotificationHelper.PUSH_TYPE_MESSAGE, CustomPushNotificationHelper.PUSH_TYPE_SESSION -> {
val shareModule = ShareModule.getInstance()
val currentActivityName = shareModule?.currentActivityName ?: ""
Log.i("ReactNative", currentActivityName)
if (!mAppLifecycleFacade.isAppVisible() || currentActivityName != "MainActivity") {
var createSummary = type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE
if (type == CustomPushNotificationHelper.PUSH_TYPE_MESSAGE) {
channelId?.let {
val notificationBundle = mNotificationProps.asBundle()
serverUrl?.let {
val notificationResult = dataHelper.fetchAndStoreDataForPushNotification(notificationBundle, isReactInit)
notificationResult?.let { result ->
notificationBundle.putBundle("data", result)
mNotificationProps = createProps(notificationBundle)
}
}
createSummary = NotificationHelper.addNotificationToPreferences(mContext, notificationId, notificationBundle)
}
}
buildNotification(notificationId, createSummary)
}
}
CustomPushNotificationHelper.PUSH_TYPE_CLEAR -> NotificationHelper.clearChannelOrThreadNotifications(mContext, mNotificationProps.asBundle())
}
if (isReactInit) {
notifyReceivedToJS()
}
}
private fun buildNotification(notificationId: Int, createSummary: Boolean) {
val pendingIntent = NotificationIntentAdapter.createPendingNotificationIntent(mContext, mNotificationProps)
val notification = buildNotification(pendingIntent)
if (createSummary) {
val summary = getNotificationSummaryBuilder(pendingIntent).build()
super.postNotification(summary, notificationId + 1)
}
super.postNotification(notification, notificationId)
}
override fun getNotificationBuilder(intent: PendingIntent): NotificationCompat.Builder {
val bundle = mNotificationProps.asBundle()
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, false)
}
private fun getNotificationSummaryBuilder(intent: PendingIntent): NotificationCompat.Builder {
val bundle = mNotificationProps.asBundle()
return CustomPushNotificationHelper.createNotificationBuilder(mContext, intent, bundle, true)
}
private fun notifyReceivedToJS() {
mJsIOHelper.sendEventToJS(NOTIFICATION_RECEIVED_EVENT_NAME, mNotificationProps.asBundle(), mAppLifecycleFacade.runningReactContext)
}
private fun addServerUrlToBundle(bundle: Bundle): String? {
val dbHelper = DatabaseHelper.instance
val serverId = bundle.getString("server_id")
var serverUrl: String? = null
dbHelper?.let {
serverUrl = if (serverId == null) {
it.onlyServerUrl
} else {
it.getServerUrlForIdentifier(serverId)
}
if (!serverUrl.isNullOrEmpty()) {
bundle.putString("server_url", serverUrl)
mNotificationProps = createProps(bundle)
}
}
return serverUrl
}
}

View file

@ -173,10 +173,10 @@ class MainApplication : NavigationApplication(), INotificationsApplication {
defaultAppLaunchHelper: AppLaunchHelper?
): IPushNotification {
return CustomPushNotification(
context,
bundle,
defaultFacade,
defaultAppLaunchHelper,
context!!,
bundle!!,
defaultFacade!!,
defaultAppLaunchHelper!!,
JsIOHelper()
)
}

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref"/>
<exclude domain="file" path="./databases"/>
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View file

@ -0,0 +1,201 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionType} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import NavigationStore from '@store/navigation_store';
import {mockApiClient} from '@test/mock_api_client';
import TestHelper from '@test/test_helper';
import {pushNotificationEntry} from './notification';
import type ServerDataOperator from '@database/operator/server_data_operator';
jest.mock('@managers/performance_metrics_manager');
jest.mock('@store/navigation_store');
const mockedNavigationStore = jest.mocked(NavigationStore);
describe('Performance metrics are set correctly', () => {
const serverUrl = 'http://www.someserverurl.com';
let operator: ServerDataOperator;
let post: Post;
beforeAll(() => {
mockApiClient.get.mockImplementation((url: string) => {
if (url.match(/\/api\/v4\/channels\/[a-z1-90-]*\/posts/)) {
return {status: 200, ok: true, data: {order: [], posts: {}}};
}
if (url.match(/\/api\/v4\/channels\/[a-z1-90-]*\/stats/)) {
return {status: 200, ok: true, data: {}};
}
if (url.match(/\/api\/v4\/posts\/[a-z1-90-]*\/thread/)) {
return {status: 200, ok: true, data: {order: [], posts: {}}};
}
console.log(`GET ${url} not registered in the mock`);
return {status: 404, ok: false};
});
mockApiClient.post.mockImplementation((url: string) => {
if (url.match(/\/api\/v4\/channels\/members\/me\/view/)) {
return {status: 200, ok: true, data: {}};
}
console.log(`POST ${url} not registered in the mock`);
return {status: 404, ok: false};
});
mockedNavigationStore.waitUntilScreenIsTop.mockImplementation(() => Promise.resolve());
// There are no problems when running the tests for this file alone without this line
// but for some reason, when running several tests together, it fails if we don't add this.
mockedNavigationStore.getScreensInStack.mockImplementation(() => []);
});
afterAll(() => {
mockApiClient.get.mockReset();
mockApiClient.post.mockReset();
});
beforeEach(async () => {
const client = await NetworkManager.createClient(serverUrl);
expect(client).toBeTruthy();
operator = (await TestHelper.setupServerDatabase(serverUrl)).operator;
await DatabaseManager.setActiveServerDatabase(serverUrl);
post = TestHelper.fakePost(TestHelper.basicChannel!.id);
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const threadModels = await prepareThreadsFromReceivedPosts(operator, [post], true);
await operator.batchRecords(threadModels, 'test');
});
afterEach(async () => {
await TestHelper.tearDown();
NetworkManager.invalidateClient(serverUrl);
});
it('channel notification', async () => {
await operator.handleConfigs({
configs: [
{id: 'CollapsedThreads', value: 'default_on'},
{id: 'FeatureFlagCollapsedThreads', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
await pushNotificationEntry(serverUrl, {
channel_id: TestHelper.basicChannel!.id,
team_id: TestHelper.basicTeam!.id,
isCRTEnabled: false, // isCRTEnabled is not checked at this level
post_id: '', // Post ID is not checked at this level
type: '', // Type is not checked at this level
version: '', // Version is not checked at this level
});
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('CHANNEL');
});
it('thread notification', async () => {
const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id);
commentPost.root_id = post.id;
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [commentPost.id],
posts: [commentPost],
prepareRecordsOnly: false,
});
const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true);
await operator.batchRecords(threadModels, 'test');
await operator.handleConfigs({
configs: [
{id: 'CollapsedThreads', value: 'default_on'},
{id: 'FeatureFlagCollapsedThreads', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
await pushNotificationEntry(serverUrl, {
root_id: post.id,
channel_id: TestHelper.basicChannel!.id,
team_id: TestHelper.basicTeam!.id,
isCRTEnabled: false, // isCRTEnabled is not checked at this level
post_id: '', // Post ID is not checked at this level
type: '', // Type is not checked at this level
version: '', // Version is not checked at this level
});
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('THREAD');
});
it('thread notification with wrong root id', async () => {
const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id);
commentPost.root_id = post.id;
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [commentPost.id],
posts: [commentPost],
prepareRecordsOnly: false,
});
const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true);
await operator.batchRecords(threadModels, 'test');
await operator.handleConfigs({
configs: [
{id: 'CollapsedThreads', value: 'default_on'},
{id: 'FeatureFlagCollapsedThreads', value: 'true'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
await pushNotificationEntry(serverUrl, {
root_id: commentPost.id,
channel_id: TestHelper.basicChannel!.id,
team_id: TestHelper.basicTeam!.id,
isCRTEnabled: false, // isCRTEnabled is not checked at this level
post_id: '', // Post ID is not checked at this level
type: '', // Type is not checked at this level
version: '', // Version is not checked at this level
});
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('THREAD');
});
it('thread notification with non crt', async () => {
const commentPost = TestHelper.fakePost(TestHelper.basicChannel!.id);
commentPost.root_id = post.id;
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [commentPost.id],
posts: [commentPost],
prepareRecordsOnly: false,
});
const threadModels = await prepareThreadsFromReceivedPosts(operator, [commentPost], true);
await operator.batchRecords(threadModels, 'test');
await operator.handleConfigs({
configs: [
{id: 'CollapsedThreads', value: 'disabled'},
{id: 'FeatureFlagCollapsedThreads', value: 'false'},
],
configsToDelete: [],
prepareRecordsOnly: false,
});
await pushNotificationEntry(serverUrl, {
root_id: post.id,
channel_id: TestHelper.basicChannel!.id,
team_id: TestHelper.basicTeam!.id,
isCRTEnabled: false, // isCRTEnabled is not checked at this level
post_id: '', // Post ID is not checked at this level
type: '', // Type is not checked at this level
version: '', // Version is not checked at this level
});
expect(PerformanceMetricsManager.setLoadTarget).toHaveBeenCalledWith('CHANNEL');
});
});

View file

@ -7,6 +7,7 @@ import {fetchMyTeam} from '@actions/remote/team';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import {getDefaultThemeByAppearance} from '@context/theme';
import DatabaseManager from '@database/manager';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getMyChannel} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
@ -103,13 +104,16 @@ export async function pushNotificationEntry(serverUrl: string, notification: Not
const actualRootId = post && ('root_id' in post ? post.root_id : post.rootId);
if (actualRootId) {
PerformanceMetricsManager.setLoadTarget('THREAD');
await fetchAndSwitchToThread(serverUrl, actualRootId, true);
} else if (post) {
PerformanceMetricsManager.setLoadTarget('THREAD');
await fetchAndSwitchToThread(serverUrl, rootId, true);
} else {
emitNotificationError('Post');
}
} else {
PerformanceMetricsManager.setLoadTarget('CHANNEL');
await switchToChannelById(serverUrl, channelId, teamId);
}
}

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {mockApiClient} from '@test/mock_api_client';
import {sendPerformanceReport} from './performance';
describe('sendPerformanceReport', () => {
const serverUrl = 'http://www.someserverurl.com';
const report: PerformanceReport = {
counters: [],
start: 1234,
end: 1235,
histograms: [
{
metric: 'metric1',
timestamp: 1234,
value: 123,
},
{
metric: 'metric1',
timestamp: 1234,
value: 124,
},
{
metric: 'metric2',
timestamp: 1234,
value: 125,
},
],
labels: {
agent: 'rnapp',
platform: 'ios',
},
version: '0.1.0',
};
beforeAll(() => {
mockApiClient.post.mockImplementation(() => ({status: 200, ok: true}));
});
afterAll(() => {
mockApiClient.post.mockReset();
});
beforeEach(async () => {
const client = await NetworkManager.createClient(serverUrl);
expect(client).toBeTruthy();
});
afterEach(async () => {
NetworkManager.invalidateClient(serverUrl);
});
it('happy path', async () => {
const {error} = await sendPerformanceReport(serverUrl, report);
expect(error).toBeFalsy();
expect(mockApiClient.post).toHaveBeenCalledWith(`${serverUrl}/api/v4/client_perf`, {body: report, headers: {}});
});
it('properly returns error', async () => {
mockApiClient.post.mockImplementationOnce(() => ({status: 404, ok: false}));
const {error} = await sendPerformanceReport(serverUrl, report);
expect(error).toBeTruthy();
});
});

View file

@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {forceLogoutIfNecessary} from './session';
export const sendPerformanceReport = async (serverUrl: string, report: PerformanceReport) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.sendPerformanceReport(report);
return {};
} catch (error) {
logDebug('error on sendPerformanceReport', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -52,7 +52,7 @@ type AuthorsRequest = {
error?: unknown;
}
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: any}> {
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: unknown}> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
@ -571,7 +571,7 @@ export async function fetchPostThread(serverUrl: string, postId: string, options
});
const result = processPostsFetched(data);
let posts: Model[] = [];
if (!fetchOnly) {
if (result.posts.length && !fetchOnly) {
const models: Model[] = [];
posts = await operator.handlePosts({
...result,

View file

@ -226,6 +226,10 @@ export default class ClientBase {
return this.getPluginRoute(Calls.PluginId);
}
getPerformanceRoute() {
return `${this.urlVersion}/client_perf`;
}
doFetch = async (url: string, options: ClientOptions, returnDataOnly = true) => {
let request;
const method = options.method?.toLowerCase();

View file

@ -24,6 +24,7 @@ export interface ClientGeneralMix {
getChannelDataRetentionPolicies: (userId: string, page?: number, perPage?: number) => Promise<PoliciesResponse<ChannelDataRetentionPolicy>>;
getRolesByNames: (rolesNames: string[]) => Promise<Role[]>;
getRedirectLocation: (urlParam: string) => Promise<Record<string, string>>;
sendPerformanceReport: (batch: PerformanceReport) => Promise<{}>;
}
const ClientGeneral = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@ -111,6 +112,13 @@ const ClientGeneral = <TBase extends Constructor<ClientBase>>(superclass: TBase)
const url = `${this.getRedirectLocationRoute()}${buildQueryString({url: urlParam})}`;
return this.doFetch(url, {method: 'get'});
};
sendPerformanceReport = async (report: PerformanceReport) => {
return this.doFetch(
this.getPerformanceRoute(),
{method: 'post', body: report},
);
};
};
export default ClientGeneral;

View file

@ -13,6 +13,7 @@ type LoadingProps = {
themeColor?: keyof Theme;
footerText?: string;
footerTextStyles?: TextStyle;
testID?: string;
}
const Loading = ({
@ -22,12 +23,16 @@ const Loading = ({
themeColor,
footerText,
footerTextStyles,
testID,
}: LoadingProps) => {
const theme = useTheme();
const indicatorColor = themeColor ? theme[themeColor] : color;
return (
<View style={containerStyle}>
<View
style={containerStyle}
testID={testID}
>
<ActivityIndicator
color={indicatorColor}
size={size}

View file

@ -154,6 +154,7 @@ const withPost = withObservables(
post: post.observe(),
thread: isCRTEnabled ? observeThreadById(database, post.id) : of$(undefined),
hasReactions,
isLastPost: of$(!nextPost),
};
});

View file

@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import NetworkManager from '@managers/network_manager';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {getPostById} from '@queries/servers/post';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Post from './post';
import type {Database} from '@nozbe/watermelondb';
import type PostModel from '@typings/database/models/servers/post';
jest.mock('@managers/performance_metrics_manager');
describe('performance metrics', () => {
let database: Database;
let post: PostModel;
function getBaseProps(): ComponentProps<typeof Post> {
return {
appsEnabled: false,
canDelete: false,
customEmojiNames: [],
differentThreadSequence: false,
hasFiles: false,
hasReactions: false,
hasReplies: false,
highlightReplyBar: false,
isEphemeral: false,
isPostAddChannelMember: false,
isPostPriorityEnabled: false,
location: 'Channel',
post,
isLastPost: true,
};
}
const serverUrl = 'http://www.someserverurl.com';
beforeEach(async () => {
const client = await NetworkManager.createClient(serverUrl);
expect(client).toBeTruthy();
database = (await TestHelper.setupServerDatabase(serverUrl)).database;
post = (await getPostById(database, TestHelper.basicPost!.id))!;
});
afterEach(async () => {
await TestHelper.tearDown();
NetworkManager.invalidateClient(serverUrl);
});
it('do not call the performance metrics if it is not the last post', () => {
const props = getBaseProps();
props.isLastPost = false;
renderWithEverything(<Post {...props}/>, {database, serverUrl});
expect(PerformanceMetricsManager.finishLoad).not.toHaveBeenCalled();
expect(PerformanceMetricsManager.endMetric).not.toHaveBeenCalled();
});
it('on channel', () => {
const props = getBaseProps();
renderWithEverything(<Post {...props}/>, {database, serverUrl});
expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('CHANNEL', serverUrl);
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl);
});
it('on thread', () => {
const props = getBaseProps();
props.location = 'Thread';
renderWithEverything(<Post {...props}/>, {database, serverUrl});
expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('THREAD', serverUrl);
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_channel_switch', serverUrl);
});
});

View file

@ -17,6 +17,7 @@ import * as Screens from '@constants/screens';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {openAsBottomSheet} from '@screens/navigation';
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
import {fromAutoResponder, isFromWebhook, isPostFailed, isPostPendingOrFailed, isSystemMessage} from '@utils/post';
@ -60,6 +61,7 @@ type PostProps = {
post: PostModel;
rootId?: string;
previousPost?: PostModel;
isLastPost: boolean;
hasReactions: boolean;
searchPatterns?: SearchPattern[];
shouldRenderReplyButton?: boolean;
@ -109,10 +111,39 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const Post = ({
appsEnabled, canDelete, currentUser, customEmojiNames, differentThreadSequence, hasFiles, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar,
isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isLastReply, isPostAcknowledgementEnabled, isPostAddChannelMember, isPostPriorityEnabled,
location, post, rootId, hasReactions, searchPatterns, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style,
testID, thread, previousPost,
appsEnabled,
canDelete,
currentUser,
customEmojiNames,
differentThreadSequence,
hasFiles,
hasReplies,
highlight,
highlightPinnedOrSaved = true,
highlightReplyBar,
isCRTEnabled,
isConsecutivePost,
isEphemeral,
isFirstReply,
isSaved,
isLastReply,
isPostAcknowledgementEnabled,
isPostAddChannelMember,
isPostPriorityEnabled,
location,
post,
rootId,
hasReactions,
searchPatterns,
shouldRenderReplyButton,
skipSavedHeader,
skipPinnedHeader,
showAddReaction = true,
style,
testID,
thread,
previousPost,
isLastPost,
}: PostProps) => {
const pressDetected = useRef(false);
const intl = useIntl();
@ -216,6 +247,19 @@ const Post = ({
};
}, [post.id]);
useEffect(() => {
if (!isLastPost) {
return;
}
if (location !== 'Channel' && location !== 'Thread') {
return;
}
PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl);
}, []);
const highlightSaved = isSaved && !skipSavedHeader;
const hightlightPinned = post.isPinned && !skipPinnedHeader;
const itemTestID = `${testID}.${post.id}`;

View file

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render, screen, userEvent} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {getTeamById} from '@queries/servers/team';
import TestHelper from '@test/test_helper';
import TeamItem from './team_item';
import type TeamModel from '@typings/database/models/servers/team';
jest.mock('@managers/performance_metrics_manager');
function getBaseProps(): ComponentProps<typeof TeamItem> {
return {
hasUnreads: false,
mentionCount: 0,
selected: false,
};
}
describe('performance metrics', () => {
const serverUrl = 'http://www.someserverurl.com';
let team: TeamModel | undefined;
beforeAll(() => {
jest.useFakeTimers({legacyFakeTimers: true});
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(async () => {
userEvent.setup({advanceTimers: jest.advanceTimersByTime});
const {database} = await TestHelper.setupServerDatabase(serverUrl);
team = await getTeamById(database, TestHelper.basicTeam!.id);
});
afterEach(async () => {
await TestHelper.tearDown();
});
it('happy path', async () => {
const baseProps = getBaseProps();
baseProps.team = team;
render(<TeamItem {...baseProps}/>);
const button = await screen.findByTestId(`team_sidebar.team_list.team_item.${team!.id}.not_selected`);
await userEvent.press(button);
expect(PerformanceMetricsManager.startMetric).toHaveBeenCalledWith('mobile_team_switch');
});
it('do not start when the team is already selected', async () => {
const baseProps = getBaseProps();
baseProps.team = team;
baseProps.selected = true;
render(<TeamItem {...baseProps}/>);
const button = await screen.findByTestId(`team_sidebar.team_list.team_item.${team!.id}.selected`);
await userEvent.press(button);
expect(PerformanceMetricsManager.startMetric).not.toHaveBeenCalled();
});
});

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback} from 'react';
import {View} from 'react-native';
import {handleTeamChange} from '@actions/remote/team';
@ -9,6 +9,7 @@ import Badge from '@components/badge';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {makeStyleSheetFromTheme} from '@utils/theme';
import TeamIcon from './team_icon';
@ -62,6 +63,15 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const onPress = useCallback(() => {
if (!team || selected) {
return;
}
PerformanceMetricsManager.startMetric('mobile_team_switch');
handleTeamChange(serverUrl, team.id);
}, [selected, team?.id, serverUrl]);
if (!team) {
return null;
}
@ -92,7 +102,7 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro
<>
<View style={[styles.container, selected ? styles.containerSelected : undefined]}>
<TouchableWithFeedback
onPress={() => handleTeamChange(serverUrl, team.id)}
onPress={onPress}
type='opacity'
testID={teamItemTestId}
>

View file

@ -12,6 +12,7 @@ import LocalConfig from '@assets/config.json';
import {DeepLink, Events, Launch, PushNotification} from '@constants';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {getLastViewedChannelIdAndServer, getOnboardingViewed, getLastViewedThreadIdAndServer} from '@queries/app/global';
import {getThemeForCurrentTeam} from '@queries/servers/preference';
import {getCurrentUserId} from '@queries/servers/system';
@ -178,9 +179,13 @@ const launchToHome = async (props: LaunchProps) => {
const lastViewedThread = await getLastViewedThreadIdAndServer();
if (lastViewedThread && lastViewedThread.server_url === props.serverUrl && lastViewedThread.thread_id) {
PerformanceMetricsManager.setLoadTarget('THREAD');
fetchAndSwitchToThread(props.serverUrl!, lastViewedThread.thread_id);
} else if (lastViewedChannel && lastViewedChannel.server_url === props.serverUrl && lastViewedChannel.channel_id) {
PerformanceMetricsManager.setLoadTarget('CHANNEL');
switchToChannelById(props.serverUrl!, lastViewedChannel.channel_id);
} else {
PerformanceMetricsManager.setLoadTarget('HOME');
}
appEntry(props.serverUrl!);

View file

@ -0,0 +1,209 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import performance from 'react-native-performance';
import {mockApiClient} from '@test/mock_api_client';
import TestHelper from '@test/test_helper';
import NetworkManager from '../network_manager';
import {getBaseReportRequest} from './test_utils';
import PerformanceMetricsManager from '.';
const TEST_EPOCH = 1577836800000;
jest.mock('@utils/log', () => ({
logDebug: () => '',
}));
performance.timeOrigin = TEST_EPOCH;
describe('load metrics', () => {
const serverUrl = 'http://www.someserverurl.com/';
const expectedUrl = `${serverUrl}/api/v4/client_perf`;
const measure: PerformanceReportMeasure = {
metric: 'mobile_load',
timestamp: TEST_EPOCH + 61000,
value: 61000,
};
beforeEach(async () => {
NetworkManager.createClient(serverUrl);
const {operator} = await TestHelper.setupServerDatabase(serverUrl);
await operator.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false});
jest.useFakeTimers({doNotFake: [
'cancelAnimationFrame',
'cancelIdleCallback',
'clearImmediate',
'clearInterval',
'clearTimeout',
'hrtime',
'nextTick',
'queueMicrotask',
'requestAnimationFrame',
'requestIdleCallback',
'setImmediate',
'setInterval',
]}).setSystemTime(new Date(TEST_EPOCH));
});
afterEach(async () => {
jest.useRealTimers();
NetworkManager.invalidateClient(serverUrl);
await TestHelper.tearDown();
});
it('only load on target', async () => {
performance.mark('nativeLaunchStart');
const expectedRequest = getBaseReportRequest(measure.timestamp, measure.timestamp + 1);
expectedRequest.body.histograms = [measure];
PerformanceMetricsManager.setLoadTarget('CHANNEL');
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
PerformanceMetricsManager.finishLoad('CHANNEL', serverUrl);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
});
});
describe('other metrics', () => {
const serverUrl1 = 'http://www.someserverurl.com/';
const expectedUrl1 = `${serverUrl1}/api/v4/client_perf`;
const serverUrl2 = 'http://www.otherserverurl.com/';
const expectedUrl2 = `${serverUrl2}/api/v4/client_perf`;
const measure1: PerformanceReportMeasure = {
metric: 'mobile_channel_switch',
timestamp: TEST_EPOCH + 100,
value: 100,
};
const measure2: PerformanceReportMeasure = {
metric: 'mobile_team_switch',
timestamp: TEST_EPOCH + 150 + 50,
value: 150,
};
beforeEach(async () => {
NetworkManager.createClient(serverUrl1);
NetworkManager.createClient(serverUrl2);
const {operator: operator1} = await TestHelper.setupServerDatabase(serverUrl1);
const {operator: operator2} = await TestHelper.setupServerDatabase(serverUrl2);
await operator1.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false});
await operator2.handleConfigs({configs: [{id: 'EnableClientMetrics', value: 'true'}], configsToDelete: [], prepareRecordsOnly: false});
jest.useFakeTimers({doNotFake: [
'cancelAnimationFrame',
'cancelIdleCallback',
'clearImmediate',
'clearInterval',
'clearTimeout',
'hrtime',
'nextTick',
'queueMicrotask',
'requestAnimationFrame',
'requestIdleCallback',
'setImmediate',
'setInterval',
]}).setSystemTime(new Date(TEST_EPOCH));
});
afterEach(async () => {
jest.useRealTimers();
NetworkManager.invalidateClient(serverUrl1);
NetworkManager.invalidateClient(serverUrl2);
await TestHelper.tearDown();
});
it('do not send metrics when we do not start them', async () => {
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
});
it('send metric after it has been started', async () => {
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest.body.histograms = [measure1];
PerformanceMetricsManager.startMetric('mobile_channel_switch');
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest);
});
it('a second end metric does not generate a second measure', async () => {
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest.body.histograms = [measure1];
PerformanceMetricsManager.startMetric('mobile_channel_switch');
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest);
});
it('different metrics do not interfere', async () => {
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
expectedRequest.body.histograms = [measure1, measure2];
PerformanceMetricsManager.startMetric('mobile_channel_switch');
jest.advanceTimersByTime(50);
PerformanceMetricsManager.startMetric('mobile_team_switch');
jest.advanceTimersByTime(50);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest);
});
it('metrics to different servers do not interfere', async () => {
const expectedRequest1 = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest1.body.histograms = [measure1];
const expectedRequest2 = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1);
expectedRequest2.body.histograms = [measure2];
PerformanceMetricsManager.startMetric('mobile_channel_switch');
jest.advanceTimersByTime(50);
PerformanceMetricsManager.startMetric('mobile_team_switch');
jest.advanceTimersByTime(50);
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl1);
await TestHelper.tick();
jest.advanceTimersByTime(100);
PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl2);
await TestHelper.tick();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl1, expectedRequest1);
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl2, expectedRequest2);
});
});

View file

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppState, type AppStateStatus} from 'react-native';
import performance from 'react-native-performance';
import Batcher from './performance_metrics_batcher';
type Target = 'HOME' | 'CHANNEL' | 'THREAD' | undefined;
type MetricName = 'mobile_channel_switch' |
'mobile_team_switch';
class PerformanceMetricsManager {
private target: Target;
private batchers: {[serverUrl: string]: Batcher} = {};
private hasRegisteredLoad = false;
private lastAppStateIsActive = AppState.currentState === 'active';
constructor() {
AppState.addEventListener('change', (appState) => this.onAppStateChange(appState));
}
private onAppStateChange(appState: AppStateStatus) {
const isAppStateActive = appState === 'active';
if (this.lastAppStateIsActive !== isAppStateActive && !isAppStateActive) {
for (const batcher of Object.values(this.batchers)) {
batcher.forceSend();
}
}
this.lastAppStateIsActive = isAppStateActive;
}
private ensureBatcher(serverUrl: string) {
if (this.batchers[serverUrl]) {
return this.batchers[serverUrl];
}
this.batchers[serverUrl] = new Batcher(serverUrl);
return this.batchers[serverUrl];
}
public setLoadTarget(target: Target) {
this.target = target;
}
public finishLoad(location: Target, serverUrl: string) {
if (this.target !== location || this.hasRegisteredLoad) {
return;
}
const measure = performance.measure('mobile_load', 'nativeLaunchStart');
this.ensureBatcher(serverUrl).addToBatch({
metric: 'mobile_load',
value: measure.duration,
timestamp: Date.now(),
});
performance.clearMeasures('mobile_load');
this.hasRegisteredLoad = true;
}
public startMetric(metricName: MetricName) {
performance.mark(metricName);
}
public endMetric(metricName: MetricName, serverUrl: string) {
const marks = performance.getEntriesByName(metricName, 'mark');
if (!marks.length) {
return;
}
const measureName = `${metricName}_measure`;
const measure = performance.measure(measureName, metricName);
this.ensureBatcher(serverUrl).addToBatch({
metric: metricName,
value: measure.duration,
timestamp: Date.now(),
});
performance.clearMarks(metricName);
performance.clearMeasures(measureName);
}
}
export default new PerformanceMetricsManager();

View file

@ -0,0 +1,195 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import NetworkManager from '@managers/network_manager';
import {mockApiClient} from '@test/mock_api_client';
import TestHelper from '@test/test_helper';
import Batcher from './performance_metrics_batcher';
import {getBaseReportRequest} from './test_utils';
import type ServerDataOperator from '@database/operator/server_data_operator';
jest.mock('@utils/log', () => ({
logDebug: () => '',
}));
describe('perfromance metrics batcher', () => {
const serverUrl = 'http://www.someserverurl.com';
const expectedUrl = `${serverUrl}/api/v4/client_perf`;
const measure1: PerformanceReportMeasure = {
metric: 'someMetric',
timestamp: 1234,
value: 1.5,
};
let operator: ServerDataOperator;
const measure2: PerformanceReportMeasure = {
metric: 'someOtherMetric',
timestamp: 1235,
value: 2.5,
};
const measure3: PerformanceReportMeasure = {
metric: 'yetAnother',
timestamp: 1236,
value: 0.5,
};
async function setMetricsConfig(value: string) {
await operator.handleConfigs({configs: [{id: 'EnableClientMetrics', value}], configsToDelete: [], prepareRecordsOnly: false});
}
beforeEach(async () => {
NetworkManager.createClient(serverUrl);
operator = (await TestHelper.setupServerDatabase(serverUrl)).operator;
await setMetricsConfig('true');
jest.useFakeTimers({doNotFake: [
'Date',
'cancelAnimationFrame',
'cancelIdleCallback',
'clearImmediate',
'clearInterval',
'clearTimeout',
'hrtime',
'nextTick',
'performance',
'queueMicrotask',
'requestAnimationFrame',
'requestIdleCallback',
'setImmediate',
'setInterval',
]});
});
afterEach(async () => {
jest.useRealTimers();
NetworkManager.invalidateClient(serverUrl);
await TestHelper.tearDown();
});
it('properly send batches only after timeout', async () => {
const batcher = new Batcher(serverUrl);
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
expectedRequest.body.histograms = [measure1, measure2];
batcher.addToBatch(measure1);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
batcher.addToBatch(measure2);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
});
it('properly set end after start when only one element', async () => {
const batcher = new Batcher(serverUrl);
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest.body.histograms = [measure1];
batcher.addToBatch(measure1);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
});
it('send the batch directly after maximum batch size is reached', async () => {
const batcher = new Batcher(serverUrl);
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
for (let i = 0; i < 99; i++) {
batcher.addToBatch(measure1);
expectedRequest.body.histograms.push(measure1);
}
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
batcher.addToBatch(measure2);
expectedRequest.body.histograms.push(measure2);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledTimes(1);
});
it('do not send batches when the config is set to false', async () => {
await setMetricsConfig('false');
const batcher = new Batcher(serverUrl);
batcher.addToBatch(measure2);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
});
it('do not send batches when the config is set to false even on force send', async () => {
await setMetricsConfig('false');
const batcher = new Batcher(serverUrl);
batcher.addToBatch(measure2);
batcher.forceSend();
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
});
it('old elements do not drip into the next batch', async () => {
const batcher = new Batcher(serverUrl);
let expectedRequest = getBaseReportRequest(measure1.timestamp, measure1.timestamp + 1);
expectedRequest.body.histograms = [measure1];
batcher.addToBatch(measure1);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest);
expectedRequest = getBaseReportRequest(measure2.timestamp, measure2.timestamp + 1);
expectedRequest.body.histograms = [];
for (let i = 0; i < 100; i++) {
batcher.addToBatch(measure2);
expectedRequest.body.histograms.push(measure2);
}
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest);
expectedRequest = getBaseReportRequest(measure3.timestamp, measure3.timestamp + 1);
expectedRequest.body.histograms = [measure3];
batcher.addToBatch(measure3);
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenLastCalledWith(expectedUrl, expectedRequest);
});
it('force send sends the batch, and does not get resent after the timeout', async () => {
const batcher = new Batcher(serverUrl);
const expectedRequest = getBaseReportRequest(measure1.timestamp, measure2.timestamp);
expectedRequest.body.histograms = [measure1, measure2];
batcher.addToBatch(measure1);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
batcher.addToBatch(measure2);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).toHaveBeenCalledWith(expectedUrl, expectedRequest);
mockApiClient.post.mockClear();
jest.advanceTimersByTime(61000);
await TestHelper.tick();
expect(mockApiClient.post).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Platform} from 'react-native';
import {sendPerformanceReport} from '@actions/remote/performance';
import DatabaseManager from '@database/manager';
import {getConfigValue} from '@queries/servers/system';
import {toMilliseconds} from '@utils/datetime';
import {logDebug} from '@utils/log';
const MAX_BATCH_SIZE = 100;
const INTERVAL_TIME = toMilliseconds({seconds: 60});
class Batcher {
private batch: PerformanceReportMeasure[] = [];
private serverUrl: string;
private sendTimeout: NodeJS.Timeout | undefined;
constructor(serverUrl: string) {
this.serverUrl = serverUrl;
}
private started() {
return Boolean(this.sendTimeout);
}
private clearTimeout() {
clearTimeout(this.sendTimeout);
this.sendTimeout = undefined;
}
private start() {
this.clearTimeout();
this.sendTimeout = setTimeout(() => this.sendBatch(), INTERVAL_TIME);
}
private async sendBatch() {
this.clearTimeout();
if (this.batch.length === 0) {
return;
}
const toSend = this.getReport();
// Empty the batch as soon as possible to avoid race conditions
this.batch = [];
const database = DatabaseManager.serverDatabases[this.serverUrl]?.database;
if (!database) {
return;
}
const clientPerformanceSetting = await getConfigValue(database, 'EnableClientMetrics');
if (clientPerformanceSetting !== 'true') {
return;
}
await sendPerformanceReport(this.serverUrl, toSend);
}
private getReport(): PerformanceReport {
let start = this.batch[0].timestamp;
let end = this.batch[0].timestamp;
for (const measure of this.batch) {
start = Math.min(start, measure.timestamp);
end = Math.max(end, measure.timestamp);
}
if (start === end) {
end += 1;
}
return {
version: '0.1.0',
labels: {
platform: Platform.select({ios: 'ios', default: 'android'}),
agent: 'rnapp',
},
start,
end,
counters: [],
histograms: this.batch,
};
}
public addToBatch(measure: PerformanceReportMeasure) {
if (!this.started()) {
this.start();
}
logDebug('Performance metric:', measure);
this.batch.push(measure);
if (this.batch.length >= MAX_BATCH_SIZE) {
this.sendBatch();
}
}
public forceSend() {
this.sendBatch();
}
}
export default Batcher;

View file

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function getBaseReportRequest(start: number, end: number): {body: PerformanceReport; headers: {}} {
return {
body: {
version: '0.1.0',
start,
end,
labels: {agent: 'rnapp', platform: 'ios'},
histograms: [],
counters: [],
},
headers: {},
};
}

View file

@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type PerformanceReportMeasure = {
metric: string;
value: number;
timestamp: number;
}
type PerformanceReport = {
version: '0.1.0';
labels: {
platform: PlatformLabel;
agent: 'rnapp';
};
start: number;
end: number;
counters: PerformanceReportMeasure[];
histograms: PerformanceReportMeasure[];
}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import BottomSheetM, {BottomSheetBackdrop, type BottomSheetBackdropProps, type BottomSheetFooterProps} from '@gorhom/bottom-sheet';
import BottomSheetM, {BottomSheetBackdrop, type BottomSheetBackdropProps} from '@gorhom/bottom-sheet';
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react';
import {DeviceEventEmitter, type Handle, InteractionManager, Keyboard, type StyleProp, View, type ViewStyle} from 'react-native';
@ -27,7 +27,7 @@ type Props = {
componentId: AvailableScreens;
contentStyle?: StyleProp<ViewStyle>;
initialSnapIndex?: number;
footerComponent?: React.FC<BottomSheetFooterProps>;
footerComponent?: React.FC<unknown>;
renderContent: () => ReactNode;
snapPoints?: Array<string | number>;
testID?: string;
@ -188,10 +188,12 @@ const BottomSheet = ({
);
if (isTablet) {
const FooterComponent = footerComponent;
return (
<>
<View style={styles.separator}/>
{renderContainerContent()}
{FooterComponent && (<FooterComponent/>)}
</>
);
}

View file

@ -5,6 +5,7 @@ import React, {useCallback} from 'react';
import {DeviceEventEmitter, StyleSheet} from 'react-native';
import {Events} from '@constants';
import {useIsTablet} from '@hooks/device';
import BottomSheet from '@screens/bottom_sheet';
import Picker from './picker';
@ -25,6 +26,8 @@ const style = StyleSheet.create({
});
const EmojiPickerScreen = ({closeButtonId, componentId, onEmojiPress}: Props) => {
const isTablet = useIsTablet();
const handleEmojiPress = useCallback((emoji: string) => {
onEmojiPress(emoji);
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
@ -46,7 +49,7 @@ const EmojiPickerScreen = ({closeButtonId, componentId, onEmojiPress}: Props) =>
componentId={componentId}
contentStyle={style.contentStyle}
initialSnapIndex={1}
footerComponent={PickerFooter}
footerComponent={isTablet ? undefined : PickerFooter}
testID='post_options'
/>
);

View file

@ -2,14 +2,19 @@
// See LICENSE.txt for license information.
import React from 'react';
import {DeviceEventEmitter} from 'react-native';
import {renderWithEverything} from '@test/intl-test-helper';
import {Events} from '@constants';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {renderWithEverything, act, waitFor, screen, waitForElementToBeRemoved} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Categories from '.';
import type Database from '@nozbe/watermelondb/Database';
jest.mock('@managers/performance_metrics_manager');
describe('components/channel_list/categories', () => {
let database: Database;
beforeAll(async () => {
@ -17,6 +22,10 @@ describe('components/channel_list/categories', () => {
database = server.database;
});
afterAll(async () => {
await TestHelper.tearDown();
});
it('render without error', () => {
const wrapper = renderWithEverything(
<Categories/>,
@ -26,3 +35,37 @@ describe('components/channel_list/categories', () => {
expect(wrapper.toJSON()).toBeTruthy();
});
});
describe('performance metrics', () => {
let database: Database;
const serverUrl = 'http://www.someserverurl.com';
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase(serverUrl);
database = server.database;
});
afterAll(async () => {
await TestHelper.tearDown();
});
it('properly send metric on load', () => {
renderWithEverything(<Categories/>, {database, serverUrl});
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledWith('mobile_team_switch', serverUrl);
});
it('properly call again after switching teams', async () => {
renderWithEverything(<Categories/>, {database, serverUrl});
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1);
act(() => {
DeviceEventEmitter.emit(Events.TEAM_SWITCH, true);
});
await waitFor(() => expect(screen.queryByTestId('categories.loading')).toBeVisible());
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(1);
act(() => {
DeviceEventEmitter.emit(Events.TEAM_SWITCH, false);
});
await waitForElementToBeRemoved(() => screen.queryByTestId('categories.loading'));
expect(PerformanceMetricsManager.endMetric).toHaveBeenCalledTimes(2);
expect(PerformanceMetricsManager.endMetric).toHaveBeenLastCalledWith('mobile_team_switch', serverUrl);
});
});

View file

@ -10,6 +10,7 @@ import Loading from '@components/loading';
import {useServerUrl} from '@context/server';
import {useIsTablet} from '@hooks/device';
import {useTeamSwitch} from '@hooks/team_switch';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import CategoryBody from './body';
import LoadCategoriesError from './error';
@ -68,6 +69,7 @@ const Categories = ({
const [initiaLoad, setInitialLoad] = useState(!categoriesToShow.length);
const onChannelSwitch = useCallback(async (c: Channel | ChannelModel) => {
PerformanceMetricsManager.startMetric('mobile_channel_switch');
switchToChannelById(serverUrl, c.id);
}, [serverUrl]);
@ -103,6 +105,14 @@ const Categories = ({
return () => clearTimeout(t);
}, []);
useEffect(() => {
if (switchingTeam) {
return;
}
PerformanceMetricsManager.endMetric('mobile_team_switch', serverUrl);
}, [switchingTeam]);
if (!categories.length) {
return <LoadCategoriesError/>;
}
@ -139,6 +149,7 @@ const Categories = ({
<Loading
size='large'
themeColor='sidebarText'
testID='categories.loading'
/>
</View>
)}

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ChannelListScreen from './channel_list';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@managers/performance_metrics_manager');
jest.mock('@react-navigation/native', () => ({
useIsFocused: () => true,
useNavigation: () => ({isFocused: () => true}),
useRoute: () => ({}),
}));
function getBaseProps(): ComponentProps<typeof ChannelListScreen> {
return {
hasChannels: true,
hasCurrentUser: true,
hasMoreThanOneTeam: true,
hasTeams: true,
isCRTEnabled: true,
isLicensed: true,
launchType: 'normal',
showIncomingCalls: true,
showToS: false,
currentUserId: 'someId',
};
}
describe('performance metrics', () => {
let database: Database;
const serverUrl = 'http://www.someserverurl.com';
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase(serverUrl);
database = server.database;
});
it('finish load on load', () => {
jest.useFakeTimers();
const props = getBaseProps();
renderWithEverything(<ChannelListScreen {...props}/>, {database, serverUrl});
expect(PerformanceMetricsManager.finishLoad).toHaveBeenCalledWith('HOME', serverUrl);
jest.useRealTimers();
});
});

View file

@ -18,6 +18,7 @@ import {Navigation as NavigationConstants, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {resetToTeams, openToS} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {isMainActivity} from '@utils/helpers';
@ -169,6 +170,10 @@ const ChannelListScreen = (props: ChannelProps) => {
}
}, []);
useEffect(() => {
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
}, []);
return (
<>
<Animated.View style={top}/>

View file

@ -61,34 +61,41 @@ const PostPriorityPickerFooter = ({onCancel, onSubmit, ...props}: Props) => {
const style = getStyleSheet(theme);
const isTablet = useIsTablet();
const footer = (
<View
style={[style.container, {
paddingBottom: FOOTER_PADDING + Platform.select({ios: (isTablet ? FOOTER_PADDING_BOTTOM_TABLET_ADJUST : 0), default: 0}),
}]}
>
<TouchableOpacity
onPress={onCancel}
style={style.cancelButton}
>
<FormattedText
id='post_priority.picker.cancel'
defaultMessage='Cancel'
style={style.cancelButtonText}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onSubmit}
style={style.applyButton}
>
<FormattedText
id='post_priority.picker.apply'
defaultMessage='Apply'
style={style.applyButtonText}
/>
</TouchableOpacity>
</View>
);
if (isTablet) {
return footer;
}
return (
<BottomSheetFooter {...props}>
<View
style={[style.container, {
paddingBottom: FOOTER_PADDING + Platform.select({ios: (isTablet ? FOOTER_PADDING_BOTTOM_TABLET_ADJUST : 0), default: 0}),
}]}
>
<TouchableOpacity
onPress={onCancel}
style={style.cancelButton}
>
<FormattedText
id='post_priority.picker.cancel'
defaultMessage='Cancel'
style={style.cancelButtonText}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onSubmit}
style={style.applyButton}
>
<FormattedText
id='post_priority.picker.apply'
defaultMessage='Apply'
style={style.applyButtonText}
/>
</TouchableOpacity>
</View>
{footer}
</BottomSheetFooter>
);
};

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {screen} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {useIsTablet} from '@hooks/device';
import {renderWithIntl} from '@test/intl-test-helper';
import PostPriorityPicker from './post_priority_picker';
jest.mock('@hooks/device');
const mockedIsTablet = jest.mocked(useIsTablet);
function getBaseProps(): ComponentProps<typeof PostPriorityPicker> {
return {
closeButtonId: '',
componentId: 'BottomSheet',
isPersistenNotificationsEnabled: true,
isPostAcknowledgementEnabled: true,
persistentNotificationInterval: 0,
postPriority: {priority: ''},
updatePostPriority: jest.fn(),
};
}
describe('post_priority_picker', () => {
it('correctly shows the apply and cancel buttons on mobile', async () => {
mockedIsTablet.mockReturnValue(false);
const props = getBaseProps();
renderWithIntl(<PostPriorityPicker {...props}/>);
expect(await screen.findByText('Apply')).toBeVisible();
expect(await screen.findByText('Cancel')).toBeVisible();
});
it('correctly shows the apply and cancel buttons on tablet', async () => {
mockedIsTablet.mockReturnValue(true);
const props = getBaseProps();
renderWithIntl(<PostPriorityPicker {...props}/>);
expect(await screen.findByText('Apply')).toBeVisible();
expect(await screen.findByText('Cancel')).toBeVisible();
});
});

View file

@ -16,6 +16,10 @@
NSString *const ReplyActionID = @"REPLY_ACTION";
typedef void (*SendReplyCompletionHandlerIMP)(id, SEL, UNNotificationResponse *, void (^)(void));
static SendReplyCompletionHandlerIMP originalSendReplyCompletionHandlerImplementation = NULL;
@implementation RNNotificationEventHandler (HandleReplyAction)
- (RNNotificationCenter *)notificationCenter{
@ -37,7 +41,16 @@ NSString *const ReplyActionID = @"REPLY_ACTION";
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
method_exchangeImplementations(originalMethod, swizzledMethod);
// Get the implementation of the swizzled method
IMP swizzledImplementation = method_getImplementation(swizzledMethod);
// Get the original implementation
IMP originalImplementation = method_getImplementation(originalMethod);
// Set the original method's implementation to the swizzled method's implementation
method_setImplementation(originalMethod, swizzledImplementation);
originalSendReplyCompletionHandlerImplementation = (SendReplyCompletionHandlerIMP)originalImplementation;
});
}
@ -140,7 +153,7 @@ NSString *const ReplyActionID = @"REPLY_ACTION";
if ([response.actionIdentifier isEqualToString:ReplyActionID]) {
[self sendReply:response completionHandler:completionHandler];
} else {
[self handleReplyAction_didReceiveNotificationResponse:response completionHandler:completionHandler];
originalSendReplyCompletionHandlerImplementation(self, @selector(sendReply:completionHandler:), response, completionHandler);
}
}

View file

@ -8,6 +8,9 @@
@import react_native_network_client;
#import <objc/runtime.h>
typedef id (*InitWithRequestInSessionOptionsContextIMP)(id, SEL, NSURLRequest *, NSURLSession *, SDWebImageDownloaderOptions *, id);
typedef void (*URLSessionTaskDidReceiveChallengeIMP)(id, SEL, NSURLSession *, NSURLSessionTask *, NSURLAuthenticationChallenge *, void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *));
@implementation SDWebImageDownloaderOperation (Swizzle)
+ (void) load {
@ -18,6 +21,9 @@
});
}
static InitWithRequestInSessionOptionsContextIMP originalInitWithRequestInSessionOptionsContextImplementation = NULL;
static URLSessionTaskDidReceiveChallengeIMP originalURLSessionTaskDidReceiveChallengeImplementation = NULL;
+ (void) swizzleInitMethod {
Class class = [self class];
@ -27,7 +33,16 @@
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
method_exchangeImplementations(originalMethod, swizzledMethod);
// Get the implementation of the swizzled method
IMP swizzledImplementation = method_getImplementation(swizzledMethod);
// Get the original implementation
IMP originalImplementation = method_getImplementation(originalMethod);
// Set the original method's implementation to the swizzled method's implementation
method_setImplementation(originalMethod, swizzledImplementation);
originalInitWithRequestInSessionOptionsContextImplementation = (InitWithRequestInSessionOptionsContextIMP)originalImplementation;
}
@ -40,7 +55,16 @@
Method originalMethod = class_getInstanceMethod(class, originalSelector);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
method_exchangeImplementations(originalMethod, swizzledMethod);
// Get the implementation of the swizzled method
IMP swizzledImplementation = method_getImplementation(swizzledMethod);
// Get the original implementation
IMP originalImplementation = method_getImplementation(originalMethod);
// Set the original method's implementation to the swizzled method's implementation
method_setImplementation(originalMethod, swizzledImplementation);
originalURLSessionTaskDidReceiveChallengeImplementation = (URLSessionTaskDidReceiveChallengeIMP)originalImplementation;
}
#pragma mark - Method Swizzling
@ -56,14 +80,14 @@
// our BearerAuthenticationAdapter.
NSURLSessionConfiguration *configuration = [nativeClientSessionManager getSessionConfigurationFor:sessionBaseUrl];
NSURLSession *newSession = [NSURLSession sessionWithConfiguration:configuration
delegate:self
delegate:self
delegateQueue:session.delegateQueue];
NSURLRequest *authorizedRequest = [BearerAuthenticationAdapter addAuthorizationBearerTokenTo:request withSessionBaseUrlString:sessionBaseUrl.absoluteString];
return [self swizzled_initWithRequest:authorizedRequest inSession:newSession options:options context:context];
return originalInitWithRequestInSessionOptionsContextImplementation(self, @selector(initWithRequest:inSession:options:context:), authorizedRequest, session, &options, context);
}
return [self swizzled_initWithRequest:request inSession:session options:options context:context];
return originalInitWithRequestInSessionOptionsContextImplementation(self, @selector(initWithRequest:inSession:options:context:), request, session, &options, context);
}
@ -95,7 +119,7 @@
return;
}
[self swizzled_URLSession:session task:task didReceiveChallenge:challenge completionHandler:completionHandler];
originalURLSessionTaskDidReceiveChallengeImplementation(self, @selector(URLSession:task:didReceiveChallenge:completionHandler:), session, task, challenge, completionHandler);
}
@end

View file

@ -952,6 +952,8 @@ PODS:
- react-native-paste-input (0.7.1):
- React-Core
- Swime (= 3.0.6)
- react-native-performance (5.1.2):
- React-Core
- react-native-safe-area-context (4.9.0):
- React-Core
- react-native-video (5.2.1):
@ -1267,6 +1269,7 @@ DEPENDENCIES:
- "react-native-network-client (from `../node_modules/@mattermost/react-native-network-client`)"
- react-native-notifications (from `../node_modules/react-native-notifications`)
- "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)"
- react-native-performance (from `../node_modules/react-native-performance`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-video (from `../node_modules/react-native-video`)
- react-native-webrtc (from `../node_modules/react-native-webrtc`)
@ -1424,6 +1427,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-notifications"
react-native-paste-input:
:path: "../node_modules/@mattermost/react-native-paste-input"
react-native-performance:
:path: "../node_modules/react-native-performance"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-video:
@ -1581,6 +1586,7 @@ SPEC CHECKSUMS:
react-native-network-client: a7e0e465f0de5ea75cef5c557df0d9dc0adbf6a9
react-native-notifications: 4601a5a8db4ced6ae7cfc43b44d35fe437ac50c4
react-native-paste-input: d2136a8269eb8ad57d81407ee2b8a646f738a694
react-native-performance: ff93f8af3b2ee9519fd7879896aa9b8b8272691d
react-native-safe-area-context: b97eb6f9e3b7f437806c2ce5983f479f8eb5de4b
react-native-video: c26780b224543c62d5e1b2a7244a5cd1b50e8253
react-native-webrtc: 255a1172fd31525b952b36aef7b8e9a41de325e5

9
package-lock.json generated
View file

@ -79,6 +79,7 @@
"react-native-math-view": "3.9.5",
"react-native-navigation": "7.39.1",
"react-native-notifications": "5.1.0",
"react-native-performance": "5.1.2",
"react-native-permissions": "4.1.5",
"react-native-reanimated": "3.8.1",
"react-native-safe-area-context": "4.9.0",
@ -17258,6 +17259,14 @@
"react-native": "*"
}
},
"node_modules/react-native-performance": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/react-native-performance/-/react-native-performance-5.1.2.tgz",
"integrity": "sha512-l5JOJphNzox9a9icL3T6O/gEqZuqWqcbejW04WPa10m0UanBdIYrNkPFl48B3ivWw3MabpjB6GiDYv7old9/fw==",
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/react-native-permissions": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-4.1.5.tgz",

View file

@ -80,6 +80,7 @@
"react-native-math-view": "3.9.5",
"react-native-navigation": "7.39.1",
"react-native-notifications": "5.1.0",
"react-native-performance": "5.1.2",
"react-native-permissions": "4.1.5",
"react-native-reanimated": "3.8.1",
"react-native-safe-area-context": "4.9.0",

View file

@ -370,7 +370,7 @@ index 027c366..5807e79 100644
unsafeExecute(work: UnsafeExecuteOperations, callback: ResultCallback<void>): void
diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
index 2f170e0..bac0352 100644
index 2f170e0..bd87f92 100644
--- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
+++ b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabase.java
@@ -11,6 +11,8 @@ import java.util.Arrays;
@ -414,17 +414,19 @@ index 2f170e0..bac0352 100644
} else {
// On some systems there is some kind of lock on `/databases` folder ¯\_(ツ)_/¯
path = context.getDatabasePath("" + name + ".db").getPath().replace("/databases", "");
@@ -172,6 +190,10 @@ public class WMDatabase {
@@ -172,7 +190,11 @@ public class WMDatabase {
});
}
- interface TransactionFunction {
+ public void unsafeVacuum() {
+ execute("vacuum");
+ }
+
interface TransactionFunction {
+ public interface TransactionFunction {
void applyTransactionFunction();
}
diff --git a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java b/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java
index 117b2bc..57e4abb 100644
--- a/node_modules/@nozbe/watermelondb/native/android/src/main/java/com/nozbe/watermelondb/WMDatabaseBridge.java

View file

@ -0,0 +1,13 @@
diff --git a/node_modules/react-native-exception-handler/android/src/main/AndroidManifest.xml b/node_modules/react-native-exception-handler/android/src/main/AndroidManifest.xml
index 58dfc7b..47c9954 100644
--- a/node_modules/react-native-exception-handler/android/src/main/AndroidManifest.xml
+++ b/node_modules/react-native-exception-handler/android/src/main/AndroidManifest.xml
@@ -3,7 +3,7 @@
package="com.masteratul.exceptionhandler">
<application>
- <activity android:name=".DefaultErrorScreen">
+ <activity android:name=".DefaultErrorScreen" android:exported="false">
</activity>
</application>

View file

@ -16,10 +16,10 @@ index 30bb01c..bba788d 100644
events "PASSED", "SKIPPED", "FAILED", "standardOut", "standardError"
}
diff --git a/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml b/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml
index 24cd226..4bfacba 100644
index 24cd226..3aa1728 100644
--- a/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml
+++ b/node_modules/react-native-notifications/lib/android/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
@@ -3,19 +3,19 @@
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.wix.reactnativenotifications">
@ -27,7 +27,22 @@ index 24cd226..4bfacba 100644
<application>
<!--
@@ -23,6 +24,9 @@
A proxy-service that gives the library an opportunity to do some work before launching/resuming the actual application task.
-->
- <service android:name=".core.ProxyService"/>
+ <service android:name=".core.ProxyService" android:exported="false"/>
<service
android:name=".fcm.FcmInstanceIdListenerService"
- android:exported="true">
+ android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
- <action android:name="com.google.firebase.INSTANCE_ID_EVENT" />
</intent-filter>
</service>
@@ -23,6 +23,9 @@
android:name=".fcm.FcmInstanceIdRefreshHandlerService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />

View file

@ -2,11 +2,12 @@
// See LICENSE.txt for license information.
import {DatabaseProvider} from '@nozbe/watermelondb/react';
import {render} from '@testing-library/react-native';
import {render, type RenderOptions} from '@testing-library/react-native';
import React, {type ReactElement} from 'react';
import {IntlProvider} from 'react-intl';
import {SafeAreaProvider} from 'react-native-safe-area-context';
import ServerUrlProvider from '@context/server';
import {ThemeContext, getDefaultThemeByAppearance} from '@context/theme';
import {getTranslations} from '@i18n';
@ -48,13 +49,13 @@ export function renderWithIntlAndTheme(ui: ReactElement, {locale = 'en', ...rend
return render(ui, {wrapper: Wrapper, ...renderOptions});
}
export function renderWithEverything(ui: ReactElement, {locale = 'en', database, ...renderOptions}: {locale?: string; database?: Database; renderOptions?: any} = {}) {
export function renderWithEverything(ui: ReactElement, {locale = 'en', database, serverUrl, ...renderOptions}: {locale?: string; database?: Database; serverUrl?: string; renderOptions?: RenderOptions} = {}) {
function Wrapper({children}: {children: ReactElement}) {
if (!database) {
return null;
}
return (
const wrapper = (
<DatabaseProvider database={database}>
<IntlProvider
locale={locale}
@ -68,6 +69,16 @@ export function renderWithEverything(ui: ReactElement, {locale = 'en', database,
</IntlProvider>
</DatabaseProvider>
);
if (serverUrl) {
return (
<ServerUrlProvider server={{displayName: serverUrl, url: serverUrl}}>
{wrapper}
</ServerUrlProvider>
);
}
return wrapper;
}
return render(ui, {wrapper: Wrapper, ...renderOptions});

11
test/mock_api_client.ts Normal file
View file

@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {RequestOptions} from '@mattermost/react-native-network-client';
export const mockApiClient = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
get: jest.fn((url: string, options?: RequestOptions) => ({status: 200, ok: true})),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
post: jest.fn((url: string, options?: RequestOptions) => ({status: 200, ok: true})),
};

View file

@ -5,12 +5,17 @@
import {setGenerator} from '@nozbe/watermelondb/utils/common/randomId';
import * as ReactNative from 'react-native';
import 'react-native-gesture-handler/jestSetup';
import mockSafeAreaContext from 'react-native-safe-area-context/jest/mock';
import {v4 as uuidv4} from 'uuid';
import {mockApiClient} from './mock_api_client';
import type {RequestOptions} from '@mattermost/react-native-network-client';
import type {ReadDirItem, StatResult} from 'react-native-fs';
import 'react-native-gesture-handler/jestSetup';
import '@testing-library/react-native/extend-expect';
// @ts-expect-error Promise does not exists in global
global.Promise = jest.requireActual('promise');
@ -184,17 +189,11 @@ jest.doMock('react-native', () => {
jest.mock('react-native-vector-icons', () => {
const React = jest.requireActual('react');
const PropTypes = jest.requireActual('prop-types');
class CompassIcon extends React.PureComponent {
render() {
return React.createElement('Icon', this.props);
}
}
CompassIcon.propTypes = {
name: PropTypes.string,
size: PropTypes.number,
style: PropTypes.oneOfType([PropTypes.array, PropTypes.number, PropTypes.object]),
};
CompassIcon.getImageSource = jest.fn().mockResolvedValue({});
return {
createIconSet: () => CompassIcon,
@ -248,12 +247,14 @@ jest.mock('../node_modules/react-native/Libraries/EventEmitter/NativeEventEmitte
jest.mock('react-native-device-info', () => {
return {
getVersion: () => '0.0.0',
getBuildNumber: () => '0',
getModel: () => 'iPhone X',
hasNotch: () => true,
isTablet: () => false,
getApplicationName: () => 'Mattermost',
getVersion: jest.fn(() => '0.0.0'),
getBuildNumber: jest.fn(() => '0'),
getModel: jest.fn(() => 'iPhone X'),
hasNotch: jest.fn(() => true),
isTablet: jest.fn(() => false),
getApplicationName: jest.fn(() => 'Mattermost'),
getSystemName: jest.fn(() => 'ios'),
getSystemVersion: jest.fn(() => '0.0.0'),
};
});
@ -365,6 +366,8 @@ jest.mock('@screens/navigation', () => ({
popToRoot: jest.fn(() => Promise.resolve()),
dismissModal: jest.fn(() => Promise.resolve()),
dismissAllModals: jest.fn(() => Promise.resolve()),
dismissAllModalsAndPopToScreen: jest.fn(),
dismissAllModalsAndPopToRoot: jest.fn(),
dismissOverlay: jest.fn(() => Promise.resolve()),
}));
@ -384,12 +387,44 @@ jest.mock('@mattermost/react-native-emm', () => ({
useManagedConfig: () => ({}),
}));
jest.mock('@react-native-clipboard/clipboard', () => ({}));
jest.mock('react-native-document-picker', () => ({}));
jest.mock('@mattermost/react-native-network-client', () => ({
getOrCreateAPIClient: (serverUrl: string) => ({client: {
baseUrl: serverUrl,
get: (url: string, options?: RequestOptions) => mockApiClient.get(`${serverUrl}${url}`, options),
post: (url: string, options?: RequestOptions) => mockApiClient.post(`${serverUrl}${url}`, options),
invalidate: jest.fn(),
}}),
RetryTypes: {
EXPONENTIAL_RETRY: 'exponential',
},
}));
jest.mock('react-native-safe-area-context', () => mockSafeAreaContext);
jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock'));
jest.mock('react-native-permissions', () => require('react-native-permissions/mock'));
declare const global: {requestAnimationFrame: (callback: any) => void};
jest.mock('react-native-haptic-feedback', () => {
const RNHF = jest.requireActual('react-native-haptic-feedback');
return {
...RNHF,
trigger: () => '',
};
});
declare const global: {
requestAnimationFrame: (callback: () => void) => void;
performance: {
now: () => number;
};
};
global.requestAnimationFrame = (callback) => {
setTimeout(callback, 0);
};
global.performance.now = () => Date.now();

View file

@ -10,6 +10,7 @@ import nock from 'nock';
import Config from '@assets/config.json';
import {Client} from '@client/rest';
import {ActionType} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
import DatabaseManager from '@database/manager';
@ -18,7 +19,6 @@ import {generateId} from '@utils/general';
import type {APIClientInterface} from '@mattermost/react-native-network-client';
const PASSWORD = 'password1';
const DEFAULT_LOCALE = 'en';
class TestHelper {
@ -51,8 +51,8 @@ class TestHelper {
this.basicRoles = null;
}
setupServerDatabase = async () => {
const serverUrl = 'https://appv1.mattermost.com';
setupServerDatabase = async (url?: string) => {
const serverUrl = url || 'https://appv1.mattermost.com';
await DatabaseManager.init([serverUrl]);
const {database, operator} = DatabaseManager.serverDatabases[serverUrl]!;
@ -112,6 +112,13 @@ class TestHelper {
systems: [{id: SYSTEM_IDENTIFIERS.PUSH_VERIFICATION_STATUS, value: PUSH_PROXY_STATUS_VERIFIED}],
});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [this.basicPost!.id],
posts: [this.basicPost!],
prepareRecordsOnly: false,
});
return {database, operator};
};
@ -291,7 +298,7 @@ class TestHelper {
return 'success' + this.generateId() + '@simulator.amazonses.com';
};
fakePost = (channelId: string) => {
fakePost = (channelId: string, userId?: string): Post => {
const time = Date.now();
return {
@ -301,6 +308,17 @@ class TestHelper {
update_at: time,
message: `Unit Test ${this.generateId()}`,
type: '',
delete_at: 0,
edit_at: 0,
hashtags: '',
is_pinned: false,
metadata: {},
original_id: '',
pending_post_id: '',
props: {},
reply_count: 0,
root_id: '',
user_id: userId || this.generateId(),
};
};
@ -314,7 +332,7 @@ class TestHelper {
};
};
fakeTeam = () => {
fakeTeam = (): Team => {
const name = this.generateId();
let inviteId = this.generateId();
if (inviteId.length > 32) {
@ -322,6 +340,7 @@ class TestHelper {
}
return {
id: this.generateId(),
name,
display_name: `Unit Test ${name}`,
type: 'O' as const,
@ -334,6 +353,9 @@ class TestHelper {
allow_open_invite: true,
group_constrained: false,
last_team_icon_update: 0,
create_at: 0,
delete_at: 0,
update_at: 0,
};
};
@ -361,11 +383,9 @@ class TestHelper {
};
};
fakeUser = () => {
fakeUser = (): UserProfile => {
return {
email: this.fakeEmail(),
allow_marketing: true,
password: PASSWORD,
locale: DEFAULT_LOCALE,
username: this.generateId(),
first_name: this.generateId(),
@ -373,6 +393,27 @@ class TestHelper {
create_at: Date.now(),
delete_at: 0,
roles: 'system_user',
auth_service: '',
id: this.generateId(),
nickname: '',
notify_props: this.fakeNotifyProps(),
position: '',
update_at: 0,
};
};
fakeNotifyProps = (): UserNotifyProps => {
return {
channel: 'false',
comments: 'root',
desktop: 'default',
desktop_sound: 'false',
email: 'false',
first_name: 'false',
highlight_keys: '',
mention_keys: '',
push: 'default',
push_status: 'away',
};
};
@ -665,6 +706,7 @@ class TestHelper {
};
wait = (time: number) => new Promise((resolve) => setTimeout(resolve, time));
tick = () => new Promise((r) => setImmediate(r));
}
export default new TestHelper();

View file

@ -46,6 +46,7 @@ interface ClientConfig {
EnableBanner: string;
EnableBotAccountCreation: string;
EnableChannelViewedMessages: string;
EnableClientMetrics?: string;
EnableCluster: string;
EnableCommands: string;
EnableCompliance: string;