Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
850a3d5d2a | ||
|
|
78c01a74f7 | ||
|
|
1594d5e77f | ||
|
|
38d3c7d1d1 | ||
|
|
e347e0a96c | ||
|
|
272895d37f |
49 changed files with 1832 additions and 299 deletions
34
NOTICE.txt
34
NOTICE.txt
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -173,10 +173,10 @@ class MainApplication : NavigationApplication(), INotificationsApplication {
|
|||
defaultAppLaunchHelper: AppLaunchHelper?
|
||||
): IPushNotification {
|
||||
return CustomPushNotification(
|
||||
context,
|
||||
bundle,
|
||||
defaultFacade,
|
||||
defaultAppLaunchHelper,
|
||||
context!!,
|
||||
bundle!!,
|
||||
defaultFacade!!,
|
||||
defaultAppLaunchHelper!!,
|
||||
JsIOHelper()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
13
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
13
android/app/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
201
app/actions/remote/entry/notification.test.ts
Normal file
201
app/actions/remote/entry/notification.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
app/actions/remote/performance.test.ts
Normal file
67
app/actions/remote/performance.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
app/actions/remote/performance.ts
Normal file
20
app/actions/remote/performance.ts
Normal 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};
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ const withPost = withObservables(
|
|||
post: post.observe(),
|
||||
thread: isCRTEnabled ? observeThreadById(database, post.id) : of$(undefined),
|
||||
hasReactions,
|
||||
isLastPost: of$(!nextPost),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
75
app/components/post_list/post/post.test.tsx
Normal file
75
app/components/post_list/post/post.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
|
|
|
|||
209
app/managers/performance_metrics_manager/index.test.ts
Normal file
209
app/managers/performance_metrics_manager/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
85
app/managers/performance_metrics_manager/index.ts
Normal file
85
app/managers/performance_metrics_manager/index.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
16
app/managers/performance_metrics_manager/test_utils.ts
Normal file
16
app/managers/performance_metrics_manager/test_utils.ts
Normal 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: {},
|
||||
};
|
||||
}
|
||||
23
app/managers/performance_metrics_manager/types.d.ts
vendored
Normal file
23
app/managers/performance_metrics_manager/types.d.ts
vendored
Normal 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[];
|
||||
}
|
||||
|
|
@ -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/>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
51
app/screens/home/channel_list/channel_list.test.tsx
Normal file
51
app/screens/home/channel_list/channel_list.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
9
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
patches/react-native-exception-handler+2.10.10.patch
Normal file
13
patches/react-native-exception-handler+2.10.10.patch
Normal 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>
|
||||
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
11
test/mock_api_client.ts
Normal 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})),
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
1
types/api/config.d.ts
vendored
1
types/api/config.d.ts
vendored
|
|
@ -46,6 +46,7 @@ interface ClientConfig {
|
|||
EnableBanner: string;
|
||||
EnableBotAccountCreation: string;
|
||||
EnableChannelViewedMessages: string;
|
||||
EnableClientMetrics?: string;
|
||||
EnableCluster: string;
|
||||
EnableCommands: string;
|
||||
EnableCompliance: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue