diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f29aaeaa9cf8..e3dda7f8fb24 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -82,6 +82,7 @@ val configProps = Properties().apply {
val ncTestServerUsername = configProps["NC_TEST_SERVER_USERNAME"]
val ncTestServerPassword = configProps["NC_TEST_SERVER_PASSWORD"]
val ncTestServerBaseUrl = configProps["NC_TEST_SERVER_BASEURL"]
+val moengageAppId = project.properties["MOENGAGE_APP_ID"]
android {
// install this NDK version and Cmake to produce smaller APKs. Build will still work if not installed
@@ -98,6 +99,8 @@ android {
targetSdk = 36
compileSdk = 36
+ // NMC Customization
+ buildConfigField("String", "MOENGAGE_APP_ID", moengageAppId.toString())
buildConfigField("boolean", "CI", ciBuild.toString())
buildConfigField("boolean", "RUNTIME_PERF_ANALYSIS", perfAnalysis.toString())
@@ -486,6 +489,15 @@ dependencies {
testImplementation(libs.bundles.unit.test)
// endregion
+ // NMC region
+ // core moengage features
+ implementation(moengage.core)
+ // optionally add this to use the Push Templates feature
+ implementation(moengage.richNotification)
+ // optionally add this to use the InApp feature
+ implementation(moengage.inapp)
+ // endregion
+
// region Mocking support
androidTestImplementation(libs.bundles.mocking)
// endregion
@@ -520,4 +532,7 @@ dependencies {
// kotlinx.serialization
implementation(libs.kotlinx.serialization.json)
+
+ // NMC: dependency required to capture Advertising ID for Adjust & MoEngage SDK
+ implementation(libs.play.services.ads.identifier)
}
diff --git a/app/src/generic/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt b/app/src/generic/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt
index 34224244fc09..ee6f937491a8 100644
--- a/app/src/generic/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt
+++ b/app/src/generic/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt
@@ -17,4 +17,8 @@ class InAppReviewHelperImpl(appPreferences: AppPreferences) : InAppReviewHelper
override fun showInAppReview(activity: AppCompatActivity) {
}
+
+ override fun performNativeReview(activity: AppCompatActivity){
+
+ }
}
diff --git a/app/src/gplay/AndroidManifest.xml b/app/src/gplay/AndroidManifest.xml
index d566a24e8b90..da34891e9c6f 100644
--- a/app/src/gplay/AndroidManifest.xml
+++ b/app/src/gplay/AndroidManifest.xml
@@ -65,6 +65,12 @@
+
+
+
diff --git a/app/src/gplay/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt b/app/src/gplay/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt
index 01208c9a7a6c..6f4b6768de97 100644
--- a/app/src/gplay/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt
+++ b/app/src/gplay/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt
@@ -70,6 +70,10 @@ class InAppReviewHelperImpl(val appPreferences: AppPreferences) : InAppReviewHel
}
}
+ override fun performNativeReview(activity: AppCompatActivity) {
+ doAppReview(activity)
+ }
+
private fun doAppReview(activity: AppCompatActivity) {
val manager = ReviewManagerFactory.create(activity)
val request: Task = manager.requestReviewFlow()
diff --git a/app/src/gplay/java/com/owncloud/android/services/firebase/NCFirebaseMessagingService.java b/app/src/gplay/java/com/owncloud/android/services/firebase/NCFirebaseMessagingService.java
index ce6beea9b7e9..fbf4d585aa8c 100644
--- a/app/src/gplay/java/com/owncloud/android/services/firebase/NCFirebaseMessagingService.java
+++ b/app/src/gplay/java/com/owncloud/android/services/firebase/NCFirebaseMessagingService.java
@@ -13,6 +13,8 @@
import com.google.firebase.messaging.Constants.MessageNotificationKeys;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
+import com.moengage.firebase.MoEFireBaseHelper;
+import com.moengage.pushbase.MoEPushHelper;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.jobs.BackgroundJobManager;
import com.nextcloud.client.jobs.NotificationWork;
@@ -82,6 +84,12 @@ public void handleIntent(Intent intent) {
@Override
public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
Log_OC.d(TAG, "onMessageReceived");
+ // NMC: check and pass the Notification payload to MoEngage to handle it
+ if (MoEPushHelper.getInstance().isFromMoEngagePlatform(remoteMessage.getData())) {
+ MoEFireBaseHelper.getInstance().passPushPayload(getApplicationContext(), remoteMessage.getData());
+ return;
+ }
+
final Map data = remoteMessage.getData();
final String subject = data.get(NotificationWork.KEY_NOTIFICATION_SUBJECT);
final String signature = data.get(NotificationWork.KEY_NOTIFICATION_SIGNATURE);
diff --git a/app/src/huawei/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt b/app/src/huawei/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt
index 34224244fc09..ee6f937491a8 100644
--- a/app/src/huawei/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt
+++ b/app/src/huawei/java/com/nextcloud/android/appReview/InAppReviewHelperImpl.kt
@@ -17,4 +17,8 @@ class InAppReviewHelperImpl(appPreferences: AppPreferences) : InAppReviewHelper
override fun showInAppReview(activity: AppCompatActivity) {
}
+
+ override fun performNativeReview(activity: AppCompatActivity){
+
+ }
}
diff --git a/app/src/main/java/com/nextcloud/appReview/InAppReviewHelper.kt b/app/src/main/java/com/nextcloud/appReview/InAppReviewHelper.kt
index 34a054b14c13..65c7c8c07025 100644
--- a/app/src/main/java/com/nextcloud/appReview/InAppReviewHelper.kt
+++ b/app/src/main/java/com/nextcloud/appReview/InAppReviewHelper.kt
@@ -30,4 +30,12 @@ interface InAppReviewHelper {
* once all the conditions satisfies it will trigger In-App Review manager to show the flow
*/
fun showInAppReview(activity: AppCompatActivity)
+
+ /**
+ * NMC customization
+ * method to perform direct native review without the logic of app launch count
+ * this will be triggered when MoEngage push comes to show native rating
+ * === DO NOT CALL THIS FUNCTION DIRECTLY FOR ANY OTHER USE CASE ===
+ */
+ fun performNativeReview(activity: AppCompatActivity)
}
diff --git a/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt
index ebcb5f7dd4d3..e0958602f541 100644
--- a/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt
+++ b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt
@@ -23,6 +23,7 @@ import com.nextcloud.client.core.Clock
import com.nextcloud.client.preferences.AppPreferences
import com.nextcloud.common.NextcloudClient
import com.owncloud.android.MainApp
+import com.nmc.android.marketTracking.MoEngageSdkUtils
import com.owncloud.android.R
import com.owncloud.android.datamodel.ArbitraryDataProvider
import com.owncloud.android.datamodel.ArbitraryDataProviderImpl
@@ -142,6 +143,8 @@ class AccountRemovalWork(
if (userRemoved) {
eventBus.post(AccountRemovedEvent())
+ // NMC: track user logout
+ MoEngageSdkUtils.trackUserLogout(context)
}
return Result.success()
diff --git a/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt b/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt
index e2ad9e5d4dc5..193e31cfba6b 100644
--- a/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt
+++ b/app/src/main/java/com/nextcloud/utils/ShortcutUtil.kt
@@ -22,6 +22,7 @@ import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import com.nextcloud.client.account.User
+import com.nmc.android.marketTracking.MoEngageSdkUtils
import com.owncloud.android.R
import com.owncloud.android.datamodel.OCFile
import com.owncloud.android.datamodel.SyncedFolderProvider
@@ -75,6 +76,9 @@ class ShortcutUtil @Inject constructor(private val mContext: Context) {
)
ShortcutManagerCompat.requestPinShortcut(mContext, shortcutInfo, pendingIntent.intentSender)
+
+ // NMC: track pin to home screen event
+ MoEngageSdkUtils.trackPinHomeScreenEvent(mContext, file)
}
private fun createShortcutIcon(
diff --git a/app/src/main/java/com/nmc/android/marketTracking/MoEngagePropertiesHelper.kt b/app/src/main/java/com/nmc/android/marketTracking/MoEngagePropertiesHelper.kt
new file mode 100644
index 000000000000..c74ec8af33c0
--- /dev/null
+++ b/app/src/main/java/com/nmc/android/marketTracking/MoEngagePropertiesHelper.kt
@@ -0,0 +1,26 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nmc.android.marketTracking
+
+enum class EventFileType(val fileType: String) {
+ PHOTO("foto"),
+ SCAN("scan"),
+ VIDEO("video"),
+ AUDIO("audio"),
+ TEXT("text"),
+ PDF("pdf"),
+ DOCUMENT("docx"),
+ SPREADSHEET("xlsx"),
+ PRESENTATION("pptx"),
+ OTHER("other"), // default
+}
+
+enum class EventFolderType(val folderType: String) {
+ ENCRYPTED("encrypted"),
+ NOT_ENCRYPTED("not encrypted")
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/nmc/android/marketTracking/MoEngageSdkUtils.kt b/app/src/main/java/com/nmc/android/marketTracking/MoEngageSdkUtils.kt
new file mode 100644
index 000000000000..28d3ad347934
--- /dev/null
+++ b/app/src/main/java/com/nmc/android/marketTracking/MoEngageSdkUtils.kt
@@ -0,0 +1,513 @@
+/*
+ * Nextcloud - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Your Name
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+package com.nmc.android.marketTracking
+
+import android.Manifest
+import android.app.Application
+import android.content.Context
+import android.os.Build
+import com.moengage.core.DataCenter
+import com.moengage.core.MoECoreHelper
+import com.moengage.core.MoEngage
+import com.moengage.core.Properties
+import com.moengage.core.analytics.MoEAnalyticsHelper
+import com.moengage.core.config.NotificationConfig
+import com.moengage.core.enableAdIdTracking
+import com.moengage.core.enableAndroidIdTracking
+import com.moengage.core.model.AppStatus
+import com.moengage.inapp.MoEInAppHelper
+import com.moengage.inapp.listeners.OnClickActionListener
+import com.moengage.inapp.model.ClickData
+import com.moengage.inapp.model.actions.CustomAction
+import com.moengage.pushbase.MoEPushHelper
+import com.nextcloud.client.account.User
+import com.nextcloud.common.NextcloudClient
+import com.nextcloud.utils.extensions.getFormattedStringDate
+import com.nmc.android.utils.FileUtils
+import com.owncloud.android.BuildConfig
+import com.owncloud.android.R
+import com.owncloud.android.datamodel.OCFile
+import com.owncloud.android.datamodel.Template
+import com.owncloud.android.lib.common.OwnCloudClientFactory
+import com.owncloud.android.lib.common.Quota
+import com.owncloud.android.lib.common.UserInfo
+import com.owncloud.android.lib.common.accounts.AccountUtils
+import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.users.GetUserInfoRemoteOperation
+import com.owncloud.android.utils.MimeTypeUtil
+import com.owncloud.android.utils.PermissionUtil
+import kotlin.math.ceil
+import kotlin.math.floor
+import kotlin.math.round
+
+object MoEngageSdkUtils {
+
+ private const val USER_PROPERTIES__STORAGE_CAPACITY = "storage_capacity" // in GB
+ private const val USER_PROPERTIES__STORAGE_USED = "storage_used" // % of storage used
+ private const val USER_PROPERTIES__CONTACT_BACKUP = "contact_backup_on"
+ private const val USER_PROPERTIES__AUTO_UPLOAD = "auto_upload_on"
+ private const val USER_PROPERTIES__APP_VERSION = "app_version"
+
+ private const val EVENT__ACTION_BUTTON = "action_button_clicked" // when user clicks on fab + button
+ private const val EVENT__UPLOAD_FILE =
+ "upload_file" // when user uploads any file (not applicable for folder) from other apps
+ private const val EVENT__CREATE_FILE = "create_file" // when user creates any file in app
+ private const val EVENT__CREATE_FOLDER = "create_folder"
+ private const val EVENT__ADD_FAVORITE = "add_favorite"
+ private const val EVENT__SHARE_FILE = "share_file" // when user share any file using link
+ private const val EVENT__OFFLINE_AVAILABLE = "offline_available"
+ private const val EVENT__PIN_TO_HOME_SCREEN = "pin_to_homescreen"
+ private const val EVENT__ONLINE_OFFICE_USED = "online_office_used" // when user opens any office files
+
+ // screen view events when user open specific screen
+ private const val SCREEN_EVENT__FAVOURITES = "favorites"
+ private const val SCREEN_EVENT__MEDIA = "medien"
+ private const val SCREEN_EVENT__OFFLINE_FILES = "offline_files"
+ private const val SCREEN_EVENT__SHARED = "shared"
+ private const val SCREEN_EVENT__DELETED_FILES = "deleted_files"
+ private const val SCREEN_EVENT__NOTIFICATIONS = "notifications"
+
+ // properties attributes key
+ private const val PROPERTIES__FILE_TYPE = "file_type"
+ private const val PROPERTIES__FOLDER_TYPE = "folder_type"
+ private const val PROPERTIES__FILE_SIZE = "file_size" // in MB
+ private const val PROPERTIES__CREATION_DATE = "creation_date" // yyyy-MM-dd
+ private const val PROPERTIES__UPLOAD_DATE = "upload_date" // // yyyy-MM-dd
+
+ private const val KILOBYTE: Long = 1024
+ private const val MEGABYTE = KILOBYTE * 1024
+ private const val GIGABYTE = MEGABYTE * 1024
+
+ // app version code for which user attributes need to track
+ // this should be the previous version before MoEngage is included
+ // Note: will be removed in future once MoEngage feature rolled out to all devices
+ private const val OLD_VERSION_CODE = 7_29_00
+
+ private const val DATE_FORMAT = "yyyy-MM-dd"
+
+ // maximum post notification permission retry count
+ private const val PUSH_PERMISSION_REQUEST_RETRY_COUNT = 2
+
+ // key for custom action when rating campaign is triggered
+ private const val MOE_CUSTOM_ACTION_NATIVE_RATING_KEY = "show-native-rating"
+
+ @JvmStatic
+ fun initMoEngageSDK(application: Application) {
+ val moEngage = MoEngage.Builder(application, BuildConfig.MOENGAGE_APP_ID, DataCenter.DATA_CENTER_2)
+ .configureNotificationMetaData(
+ NotificationConfig(
+ R.drawable.notification_icon,
+ R.drawable.notification_icon,
+ R.color.primary,
+ false
+ )
+ )
+ .build()
+ MoEngage.initialiseDefaultInstance(moEngage)
+
+ updatePostNotificationsPermission(application)
+
+ enableDeviceIdentifierTracking(application)
+
+ // track app version at app launch
+ trackAppVersion(application)
+ }
+
+ // for NMC the default privacy tracking consent is always taken from users
+ // so the tracking will always be enabled for MoEngage
+ private fun enableDeviceIdentifierTracking(context: Context) {
+ enableAndroidIdTracking(context)
+ enableAdIdTracking(context)
+ }
+
+ private fun trackAppVersion(context: Context) {
+ MoEAnalyticsHelper.setUserAttribute(context, USER_PROPERTIES__APP_VERSION, BuildConfig.VERSION_NAME)
+ }
+
+ /**
+ * method to check if a user updated the app from older version where MoEngage was not included
+ * if user app version is old and is logged in then we have to auto capture the user attributes to map the events
+ * Note: Will be removed when MoEngage will be rolled out to all versions
+ */
+ @JvmStatic
+ fun captureUserAttrsForOldAppVersion(
+ context: Context,
+ lastSeenVersionCode: Int,
+ user: User
+ ) {
+ if (lastSeenVersionCode in 1..OLD_VERSION_CODE && !user.isAnonymous) {
+ fetchUserInfo(context, user)
+ }
+
+ // if user is not logged in for older app versions then nothing to do
+ // as the events will be captured after successful login
+ }
+
+ @JvmStatic
+ fun trackAppInstallOrUpdate(context: Context, lastSeenVersionCode: Int) {
+ if (lastSeenVersionCode <= 0) {
+ trackAppInstall(context)
+ } else if (lastSeenVersionCode < BuildConfig.VERSION_CODE) {
+ trackAppUpdate(context)
+ }
+ // For same version code no event has to send
+ }
+
+ private fun trackAppInstall(context: Context) {
+ // For Fresh Install of App
+ MoEAnalyticsHelper.setAppStatus(context, AppStatus.INSTALL)
+ }
+
+ private fun trackAppUpdate(context: Context) {
+ // For Existing user who has updated the app
+ MoEAnalyticsHelper.setAppStatus(context, AppStatus.UPDATE)
+ }
+
+ @JvmStatic
+ fun trackUserLogin(context: Context, userInfo: UserInfo) {
+ userInfo.id?.let {
+ MoEAnalyticsHelper.setUniqueId(context, it)
+ }
+ userInfo.displayName?.let {
+ MoEAnalyticsHelper.setUserName(context, it)
+ }
+ userInfo.email?.let {
+ MoEAnalyticsHelper.setEmailId(context, it)
+ }
+ trackQuotaStorage(context, userInfo.quota)
+ }
+
+ @JvmStatic
+ fun trackQuotaStorage(context: Context, quota: Quota?) {
+ quota?.let {
+ val totalQuota = if (it.quota > 0) {
+ bytesToGB(it.total).toString()
+ } else {
+ it.total.toString()
+ }
+ // capture storage capacity
+ MoEAnalyticsHelper.setUserAttribute(context, USER_PROPERTIES__STORAGE_CAPACITY, totalQuota)
+
+ val usedSpace = ceil(it.relative).toInt()
+ // capture storage used
+ MoEAnalyticsHelper.setUserAttribute(context, USER_PROPERTIES__STORAGE_USED, usedSpace)
+ }
+ }
+
+ @JvmStatic
+ fun trackContactBackup(context: Context, isEnabled: Boolean) {
+ MoEAnalyticsHelper.setUserAttribute(context, USER_PROPERTIES__CONTACT_BACKUP, isEnabled)
+ }
+
+ @JvmStatic
+ fun trackAutoUpload(context: Context, syncedFoldersCount: Int) {
+ // since multiple folders can be enabled for auto upload
+ // user can add or remove a folder anytime, and we don't have single flag to check if auto upload is enabled
+ // so we have to check the count and if there are folders more than 0 i.e. auto upload is enabled
+ MoEAnalyticsHelper.setUserAttribute(context, USER_PROPERTIES__AUTO_UPLOAD, syncedFoldersCount > 0)
+ }
+
+ @JvmStatic
+ fun trackActionButtonEvent(context: Context) {
+ MoEAnalyticsHelper.trackEvent(context, EVENT__ACTION_BUTTON, Properties())
+ }
+
+ @JvmStatic
+ fun trackUploadFileEvent(context: Context, file: OCFile, originalStoragePath: String) {
+ if (file.isFolder) return
+
+ MoEAnalyticsHelper.trackEvent(
+ context, EVENT__UPLOAD_FILE, getCommonProperties(
+ file,
+ FileUtils.isScannedFiles(context, originalStoragePath)
+ )
+ )
+ }
+
+ @JvmStatic
+ fun trackCreateFileEvent(context: Context, file: OCFile, type: Template.Type? = null) {
+ if (file.isFolder) return
+
+ val properties = Properties()
+ properties.addAttribute(PROPERTIES__FILE_TYPE, getOfficeFileType(type) { getFileType(file) }.fileType)
+ properties.addAttribute(PROPERTIES__FILE_SIZE, bytesToMBInDecimal(file.fileLength).toString())
+ properties.addAttribute(
+ PROPERTIES__CREATION_DATE,
+ // using modification timestamp as this will always have value
+ file.modificationTimestamp.getFormattedStringDate(DATE_FORMAT)
+ )
+
+ MoEAnalyticsHelper.trackEvent(context, EVENT__CREATE_FILE, properties)
+ }
+
+ @JvmStatic
+ fun trackCreateFolderEvent(context: Context, file: OCFile) {
+ if (!file.isFolder) return
+
+ val properties = Properties()
+ properties.addAttribute(PROPERTIES__FOLDER_TYPE, getFolderType(file).folderType)
+ properties.addAttribute(
+ PROPERTIES__CREATION_DATE,
+ // using modification timestamp because for folder creationTimeStamp is always 0
+ file.modificationTimestamp.getFormattedStringDate(DATE_FORMAT)
+ )
+
+ MoEAnalyticsHelper.trackEvent(context, EVENT__CREATE_FOLDER, properties)
+ }
+
+ @JvmStatic
+ fun trackAddFavoriteEvent(context: Context, file: OCFile) {
+ if (file.isFolder) return
+
+ MoEAnalyticsHelper.trackEvent(context, EVENT__ADD_FAVORITE, getCommonProperties(file))
+ }
+
+ @JvmStatic
+ fun trackShareFileEvent(context: Context, file: OCFile) {
+ if (file.isFolder) return
+
+ MoEAnalyticsHelper.trackEvent(context, EVENT__SHARE_FILE, getCommonProperties(file))
+ }
+
+ @JvmStatic
+ fun trackOfflineAvailableEvent(context: Context, file: OCFile) {
+ if (file.isFolder) return
+
+ MoEAnalyticsHelper.trackEvent(context, EVENT__OFFLINE_AVAILABLE, getCommonProperties(file))
+ }
+
+ @JvmStatic
+ fun trackPinHomeScreenEvent(context: Context, file: OCFile) {
+ if (file.isFolder) return
+
+ MoEAnalyticsHelper.trackEvent(context, EVENT__PIN_TO_HOME_SCREEN, getCommonProperties(file))
+ }
+
+ @JvmStatic
+ fun trackOnlineOfficeUsedEvent(context: Context, file: OCFile) {
+ if (file.isFolder) return
+
+ MoEAnalyticsHelper.trackEvent(context, EVENT__ONLINE_OFFICE_USED, Properties())
+ }
+
+ @JvmStatic
+ fun trackFavouriteScreenEvent(context: Context) {
+ MoEAnalyticsHelper.trackEvent(context, SCREEN_EVENT__FAVOURITES, Properties())
+ }
+
+ @JvmStatic
+ fun trackMediaScreenEvent(context: Context) {
+ MoEAnalyticsHelper.trackEvent(context, SCREEN_EVENT__MEDIA, Properties())
+ }
+
+ @JvmStatic
+ fun trackOfflineFilesScreenEvent(context: Context) {
+ MoEAnalyticsHelper.trackEvent(context, SCREEN_EVENT__OFFLINE_FILES, Properties())
+ }
+
+ @JvmStatic
+ fun trackSharedScreenEvent(context: Context) {
+ MoEAnalyticsHelper.trackEvent(context, SCREEN_EVENT__SHARED, Properties())
+ }
+
+ @JvmStatic
+ fun trackDeletedFilesScreenEvent(context: Context) {
+ MoEAnalyticsHelper.trackEvent(context, SCREEN_EVENT__DELETED_FILES, Properties())
+ }
+
+ @JvmStatic
+ fun trackNotificationsScreenEvent(context: Context) {
+ MoEAnalyticsHelper.trackEvent(context, SCREEN_EVENT__NOTIFICATIONS, Properties())
+ }
+
+ @JvmStatic
+ fun trackUserLogout(context: Context) {
+ MoECoreHelper.logoutUser(context)
+ }
+
+ private fun getCommonProperties(file: OCFile, isScan: Boolean = false): Properties {
+ val properties = Properties()
+ properties.addAttribute(PROPERTIES__FILE_TYPE, getFileType(file, isScan).fileType)
+ properties.addAttribute(PROPERTIES__FILE_SIZE, bytesToMBInDecimal(file.fileLength).toString())
+ properties.addAttribute(
+ PROPERTIES__CREATION_DATE,
+ // using modification timestamp as this will always have value
+ file.modificationTimestamp.getFormattedStringDate(DATE_FORMAT)
+ )
+ properties.addAttribute(
+ PROPERTIES__UPLOAD_DATE,
+ (file.uploadTimestamp * 1000L).getFormattedStringDate(DATE_FORMAT)
+ )
+ return properties
+ }
+
+ private fun bytesToGB(bytes: Long): Int {
+ return floor((bytes / GIGABYTE).toDouble()).toInt()
+ }
+
+ private fun bytesToMBInDecimal(bytes: Long): Double {
+ val mb = bytes.toDouble() / MEGABYTE
+ return round((mb * 10)) / 10 // Round down to 1 decimal place
+ }
+
+ private fun getFileType(file: OCFile, isScan: Boolean = false): EventFileType {
+ // if upload is happening through scan then no need to check mime type
+ // just set SCAN as type and send event
+ if (isScan) return EventFileType.SCAN
+
+ return when {
+ MimeTypeUtil.isImage(file) -> {
+ EventFileType.PHOTO
+ }
+
+ MimeTypeUtil.isVideo(file) -> {
+ EventFileType.VIDEO
+ }
+
+ MimeTypeUtil.isAudio(file) -> {
+ EventFileType.AUDIO
+ }
+
+ MimeTypeUtil.isPDF(file) -> {
+ EventFileType.PDF
+ }
+
+ MimeTypeUtil.isText(file) -> {
+ EventFileType.TEXT
+ }
+
+ else -> {
+ EventFileType.OTHER
+ }
+ }
+ }
+
+ private fun getOfficeFileType(
+ type: Template.Type?,
+ getFileType: () -> EventFileType
+ ): EventFileType {
+ return when (type) {
+ Template.Type.DOCUMENT -> {
+ EventFileType.DOCUMENT
+ }
+
+ Template.Type.SPREADSHEET -> {
+ EventFileType.SPREADSHEET
+ }
+
+ Template.Type.PRESENTATION -> {
+ EventFileType.PRESENTATION
+ }
+
+ else -> {
+ getFileType()
+ }
+ }
+ }
+
+ private fun getFolderType(file: OCFile): EventFolderType {
+ return if (file.isEncrypted) {
+ EventFolderType.ENCRYPTED
+ } else {
+ EventFolderType.NOT_ENCRYPTED
+ }
+ }
+
+ private fun fetchUserInfo(context: Context, user: User) {
+ val t = Thread(Runnable {
+ val nextcloudClient: NextcloudClient
+ try {
+ nextcloudClient = OwnCloudClientFactory.createNextcloudClient(
+ user,
+ context
+ )
+ } catch (e: AccountUtils.AccountNotFoundException) {
+ Log_OC.e(this, "Error retrieving user info", e)
+ return@Runnable
+ } catch (e: SecurityException) {
+ Log_OC.e(this, "Error retrieving user info", e)
+ return@Runnable
+ }
+
+ val result = GetUserInfoRemoteOperation().execute(nextcloudClient)
+ if (result.isSuccess && result.resultData != null) {
+ val userInfo = result.resultData
+
+ trackUserLogin(context, userInfo)
+ } else {
+ Log_OC.d(this, result.logMessage)
+ }
+ })
+
+ t.start()
+ }
+
+ @JvmStatic
+ fun updatePostNotificationsPermission(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val isGranted = PermissionUtil.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
+
+ MoEPushHelper.getInstance().pushPermissionResponse(context, isGranted)
+
+ if (!isGranted) {
+ MoEPushHelper.getInstance()
+ .updatePushPermissionRequestCount(context, PUSH_PERMISSION_REQUEST_RETRY_COUNT)
+ }
+ } else {
+ MoEPushHelper.getInstance().setUpNotificationChannels(context)
+ }
+ }
+
+ /**
+ * function should be called from onStart() of Activity
+ * or onResume() of Fragment
+ */
+ @JvmStatic
+ fun displayInAppNotification(context: Context) {
+ MoEInAppHelper.getInstance().showInApp(context)
+ }
+
+ /**
+ * To show In-App in both Portrait and Landscape mode properly
+ * when Activity is handling Config changes by itself
+ * call this function from onConfigurationChanged()
+ */
+ @JvmStatic
+ fun handleConfigChangesForInAppNotification() {
+ MoEInAppHelper.getInstance().onConfigurationChanged()
+ }
+
+ @JvmStatic
+ fun handleCustomActionCallback(actionCallback: OnHandleCustomActionCallback?) {
+ MoEInAppHelper.getInstance().setClickActionListener(object : OnClickActionListener {
+ override fun onClick(clickData: ClickData): Boolean {
+ Log_OC.d("MoEngageSDK", "Click data: $clickData")
+ val maps = (clickData.action as CustomAction).keyValuePairs
+ maps?.let {
+ if (it.containsKey(MOE_CUSTOM_ACTION_NATIVE_RATING_KEY)) {
+ val value = it.getValue(MOE_CUSTOM_ACTION_NATIVE_RATING_KEY)
+ if (value == "true") {
+ Log_OC.d("MoEngageSDK", "$MOE_CUSTOM_ACTION_NATIVE_RATING_KEY matches and its true")
+ actionCallback?.handleAction(CustomActionType.RATING)
+ return true
+ }
+ }
+ }
+ return false
+ }
+ })
+ }
+
+ enum class CustomActionType {
+ RATING
+ }
+
+ interface OnHandleCustomActionCallback {
+ fun handleAction(actionType: CustomActionType)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/nmc/android/utils/FileUtils.java b/app/src/main/java/com/nmc/android/utils/FileUtils.java
new file mode 100644
index 000000000000..97496b9a5f59
--- /dev/null
+++ b/app/src/main/java/com/nmc/android/utils/FileUtils.java
@@ -0,0 +1,171 @@
+package com.nmc.android.utils;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.os.Environment;
+import android.text.TextUtils;
+
+import com.owncloud.android.lib.common.utils.Log_OC;
+import com.owncloud.android.ui.helpers.FileOperationsHelper;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+import androidx.annotation.NonNull;
+
+// TODO: 06/24/23 Migrate to FileUtil once Rotate PR is upstreamed and merged by NC
+public class FileUtils {
+ private static final String TAG = FileUtils.class.getSimpleName();
+
+ private static final String SCANS_FILE_DIR = "Scans";
+ private static final String SCANNED_FILE_PREFIX = "scan_";
+
+ // while generating pdf using Scanbot it provide us following path:
+ // /scanbot-sdk/snapping_documents/.pdf
+ // this path will help us to differentiate if pdf file is generating by scanbot
+ private static final String SCANBOT_PDF_LOCAL_PATH = "/scanbot-sdk/snapping_documents/";
+ private static final int JPG_FILE_TYPE = 1;
+ private static final int PNG_FILE_TYPE = 2;
+
+ public static File saveJpgImage(Context context, Bitmap bitmap, String imageName, int quality) {
+ return createFileAndSaveImage(context, bitmap, imageName, quality, JPG_FILE_TYPE);
+ }
+
+ public static File savePngImage(Context context, Bitmap bitmap, String imageName, int quality) {
+ return createFileAndSaveImage(context, bitmap, imageName, quality, PNG_FILE_TYPE);
+ }
+
+ private static File createFileAndSaveImage(Context context, Bitmap bitmap, String imageName, int quality,
+ int fileType) {
+ File file = fileType == PNG_FILE_TYPE ? getPngImageName(context, imageName) : getJpgImageName(context,
+ imageName);
+ return saveImage(file, bitmap, quality, fileType);
+ }
+
+ private static File saveImage(File file, Bitmap bitmap, int quality, int fileType) {
+ try {
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ bitmap.compress(Bitmap.CompressFormat.JPEG, quality, bos);
+ byte[] bitmapData = bos.toByteArray();
+
+ FileOutputStream fileOutputStream = new FileOutputStream(file);
+ fileOutputStream.write(bitmapData);
+ fileOutputStream.flush();
+ fileOutputStream.close();
+ return file;
+ } catch (Exception e) {
+ Log_OC.e(TAG, " Failed to save image : " + e.getLocalizedMessage());
+ return null;
+ }
+ }
+
+ private static File getJpgImageName(Context context, String imageName) {
+ File imageFile = getOutputMediaFile(context);
+ if (!TextUtils.isEmpty(imageName)) {
+ return new File(imageFile.getPath() + File.separator + imageName + ".jpg");
+ } else {
+ return new File(imageFile.getPath() + File.separator + "IMG_" + FileOperationsHelper.getCapturedImageName());
+ }
+ }
+
+ private static File getPngImageName(Context context, String imageName) {
+ File imageFile = getOutputMediaFile(context);
+ if (!TextUtils.isEmpty(imageName)) {
+ return new File(imageFile.getPath() + File.separator + imageName + ".png");
+ } else {
+ return new File(imageFile.getPath() + File.separator + "IMG_" + FileOperationsHelper.getCapturedImageName().replace(".jpg", ".png"));
+ }
+ }
+
+ private static File getTextFileName(Context context, String fileName) {
+ File txtFileName = getOutputMediaFile(context);
+ if (!TextUtils.isEmpty(fileName)) {
+ return new File(txtFileName.getPath() + File.separator + fileName + ".txt");
+ } else {
+ return new File(txtFileName.getPath() + File.separator + FileOperationsHelper.getCapturedImageName().replace(".jpg", ".txt"));
+ }
+ }
+
+ public static File getPdfFileName(Context context, String fileName) {
+ File pdfFileName = getOutputMediaFile(context);
+ if (!TextUtils.isEmpty(fileName)) {
+ return new File(pdfFileName.getPath() + File.separator + fileName + ".pdf");
+ } else {
+ return new File(pdfFileName.getPath() + File.separator + FileOperationsHelper.getCapturedImageName().replace(".jpg", ".pdf"));
+ }
+ }
+
+ public static String scannedFileName() {
+ return SCANNED_FILE_PREFIX + new SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US).format(new Date());
+ }
+
+ public static File getOutputMediaFile(Context context) {
+ File file = new File(context.getExternalFilesDir(Environment.DIRECTORY_PICTURES), SCANS_FILE_DIR);
+ if (!file.exists()) {
+ file.mkdir();
+ }
+ return file;
+ }
+
+ public static Bitmap convertFileToBitmap(File file) {
+ String filePath = file.getPath();
+ Bitmap bitmap = BitmapFactory.decodeFile(filePath);
+ return bitmap;
+ }
+
+ public static File writeTextToFile(Context context, String textToWrite, String fileName) {
+ File file = getTextFileName(context, fileName);
+ try {
+ FileWriter fileWriter = new FileWriter(file);
+ fileWriter.write(textToWrite);
+ fileWriter.flush();
+ fileWriter.close();
+ return file;
+ } catch (IOException e) {
+ //e.printStackTrace();
+ Log_OC.e(TAG, "Failed to write file : " + e.toString());
+ }
+ return null;
+
+ }
+
+ /**
+ * method to check if uploading file is from Scans or not
+ *
+ * @param path local path of the uploading file
+ */
+ public static boolean isScannedFiles(@NonNull Context context, @NonNull String path) {
+ if (path.isEmpty()) {
+ return false;
+ }
+
+ return (path.contains(getOutputMediaFile(context).getPath()) || path.contains(SCANBOT_PDF_LOCAL_PATH));
+ }
+
+ /**
+ * delete all the files inside the pictures directory
+ * this directory is getting used to store the scanned images temporarily till they uploaded to cloud
+ * the scanned files after downloading will get deleted by UploadWorker but in case some files still there
+ * then we have to delete it when user do logout from the app
+ * @param context
+ */
+ public static void deleteFilesFromPicturesDirectory(Context context) {
+ File getFileDirectory = getOutputMediaFile(context);
+ if (getFileDirectory.isDirectory()) {
+ File[] fileList = getFileDirectory.listFiles();
+ if (fileList != null && fileList.length > 0) {
+ for (File file : fileList) {
+ file.delete();
+ }
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/owncloud/android/MainApp.java b/app/src/main/java/com/owncloud/android/MainApp.java
index e1b946e2781e..200d10a3c74e 100644
--- a/app/src/main/java/com/owncloud/android/MainApp.java
+++ b/app/src/main/java/com/owncloud/android/MainApp.java
@@ -63,6 +63,7 @@
import com.nextcloud.receiver.NetworkChangeReceiver;
import com.nextcloud.utils.extensions.ContextExtensionsKt;
import com.nextcloud.utils.mdm.MDMConfig;
+import com.nmc.android.marketTracking.MoEngageSdkUtils;
import com.nmc.android.ui.LauncherActivity;
import com.owncloud.android.authentication.PassCodeManager;
import com.owncloud.android.datamodel.ArbitraryDataProvider;
@@ -298,6 +299,9 @@ public void onCreate() {
registerActivityLifecycleCallbacks(new ActivityInjector());
+ // NMC: init MoEngage SDK
+ initMoEngage();
+ // NMC: end
//update the app restart count when app is launched by the user
inAppReviewHelper.resetAndIncrementAppRestartCounter();
@@ -985,6 +989,13 @@ private static void cleanOldEntries(Clock clock) {
}
}
+ private void initMoEngage(){
+ MoEngageSdkUtils.initMoEngageSDK(this);
+ MoEngageSdkUtils.trackAppInstallOrUpdate(this, preferences.getLastSeenVersionCode());
+ MoEngageSdkUtils.captureUserAttrsForOldAppVersion(this, preferences.getLastSeenVersionCode(),
+ accountManager.getUser());
+ }
+
@Override
public AndroidInjector