diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 248f60acfc60..1d336cee4227 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -139,16 +139,19 @@ android { register("generic") { applicationId = "com.nextcloud.client" dimension = "default" + buildConfigField("boolean", "DEFAULT_PUSH_UNIFIEDPUSH", "true") } register("gplay") { applicationId = "com.nextcloud.client" dimension = "default" + buildConfigField("boolean", "DEFAULT_PUSH_UNIFIEDPUSH", "false") } register("huawei") { applicationId = "com.nextcloud.client" dimension = "default" + buildConfigField("boolean", "DEFAULT_PUSH_UNIFIEDPUSH", "false") } register("versionDev") { @@ -156,6 +159,7 @@ android { dimension = "default" versionCode = 20220322 versionName = "20220322" + buildConfigField("boolean", "DEFAULT_PUSH_UNIFIEDPUSH", "false") } register("qa") { @@ -163,6 +167,7 @@ android { dimension = "default" versionCode = 1 versionName = "1" + buildConfigField("boolean", "DEFAULT_PUSH_UNIFIEDPUSH", "false") } } } @@ -500,6 +505,10 @@ dependencies { "gplayImplementation"(libs.bundles.gplay) // endregion + // region Push + implementation(libs.unifiedpush.connector) + // endregion + // region common implementation(libs.ui) implementation(libs.common.core) diff --git a/app/src/generic/java/com/owncloud/android/utils/PushUtils.java b/app/src/generic/java/com/owncloud/android/utils/PushUtils.java index 139377f210d9..50b29c2669e3 100644 --- a/app/src/generic/java/com/owncloud/android/utils/PushUtils.java +++ b/app/src/generic/java/com/owncloud/android/utils/PushUtils.java @@ -7,6 +7,7 @@ */ package com.owncloud.android.utils; +import android.accounts.Account; import android.content.Context; import com.nextcloud.client.account.UserAccountManager; @@ -22,6 +23,10 @@ public final class PushUtils { private PushUtils() { } + public static void setRegistrationForAccountEnabled(Account account, Boolean enabled) { + // do nothing + } + public static void pushRegistrationToServer(final UserAccountManager accountManager, final String pushToken) { // do nothing } diff --git a/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java b/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java index 4e77b401556e..f36ac6c01a29 100644 --- a/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java +++ b/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java @@ -128,7 +128,24 @@ private static int generateRsa2048KeyPair() { return -2; } - private static void deleteRegistrationForAccount(Account account) { + /** + * Tag the registration as disabled in the local data provider + * + * @param account + */ + public static void setRegistrationForAccountEnabled(Account account, Boolean enabled) { + String arbitraryValue; + if (!TextUtils.isEmpty(arbitraryValue = arbitraryDataProvider.getValue(account.name, KEY_PUSH))) { + Gson gson = new Gson(); + PushConfigurationState pushArbitraryData = gson.fromJson(arbitraryValue, + PushConfigurationState.class); + pushArbitraryData.shouldBeDisabled = !enabled; + if (enabled) pushArbitraryData.disabled = false; + arbitraryDataProvider.storeOrUpdateKeyValue(account.name, KEY_PUSH, gson.toJson(pushArbitraryData)); + } + } + + private static void deleteRegistrationForAccount(Account account, Boolean deleteLocalData) { Context context = MainApp.getAppContext(); OwnCloudAccount ocAccount; arbitraryDataProvider = new ArbitraryDataProviderImpl(MainApp.getAppContext()); @@ -141,7 +158,8 @@ private static void deleteRegistrationForAccount(Account account) { RemoteOperationResult remoteOperationResult = new UnregisterAccountDeviceForNotificationsOperation().execute(mClient); - if (remoteOperationResult.getHttpCode() == HttpStatus.SC_ACCEPTED) { + int status = remoteOperationResult.getHttpCode(); + if (deleteLocalData && status == HttpStatus.SC_ACCEPTED) { String arbitraryValue; if (!TextUtils.isEmpty(arbitraryValue = arbitraryDataProvider.getValue(account.name, KEY_PUSH))) { Gson gson = new Gson(); @@ -157,6 +175,19 @@ private static void deleteRegistrationForAccount(Account account) { arbitraryDataProvider.deleteKeyForAccount(account.name, KEY_PUSH); } } + } else if (!deleteLocalData && (status == HttpStatus.SC_ACCEPTED || status == HttpStatus.SC_OK)) { + String arbitraryValue; + if (!TextUtils.isEmpty(arbitraryValue = arbitraryDataProvider.getValue(account.name, KEY_PUSH))) { + Gson gson = new Gson(); + PushConfigurationState pushArbitraryData = gson.fromJson(arbitraryValue, + PushConfigurationState.class); + pushArbitraryData.disabled = true; + pushArbitraryData.pushToken = ""; + arbitraryDataProvider.storeOrUpdateKeyValue( + account.name, + KEY_PUSH, + gson.toJson(pushArbitraryData)); + } } } catch (com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException e) { Log_OC.d(TAG, "Failed to find an account"); @@ -197,9 +228,19 @@ public static void pushRegistrationToServer(final UserAccountManager accountMana accountPushData = null; } + if (accountPushData == null || providerValue.isEmpty()) { + Log_OC.d(TAG, "accountPushData is null"); + } else { + Log_OC.d(TAG, "ShouldBeDeleted=" + accountPushData.isShouldBeDeleted() + + " disabled=" + accountPushData.disabled + + " shouldBeDisabled=" + accountPushData.shouldBeDisabled + + " sameToken=" + accountPushData.getPushToken().equals(token)); + } if (accountPushData != null && !accountPushData.getPushToken().equals(token) && - !accountPushData.isShouldBeDeleted() || + !accountPushData.isShouldBeDeleted() && !accountPushData.disabled && + !accountPushData.shouldBeDisabled || TextUtils.isEmpty(providerValue)) { + Log_OC.d(TAG, "Registering " + account.name); try { OwnCloudAccount ocAccount = new OwnCloudAccount(account, context); NextcloudClient client = OwnCloudClientManagerFactory.getDefaultSingleton(). @@ -244,7 +285,11 @@ public static void pushRegistrationToServer(final UserAccountManager accountMana Log_OC.d(TAG, "Failed via OperationCanceledException"); } } else if (accountPushData != null && accountPushData.isShouldBeDeleted()) { - deleteRegistrationForAccount(account); + Log_OC.d(TAG, "Deleting " + account.name); + deleteRegistrationForAccount(account, true); + } else if (accountPushData != null && accountPushData.shouldBeDisabled && !accountPushData.disabled) { + Log_OC.d(TAG, "Disabling " + account.name); + deleteRegistrationForAccount(account, false); } } } @@ -336,11 +381,19 @@ private static int saveKeyToFile(Key key, String path) { return -1; } + /** + * Reinit keys, [pushRegistrationToServer]\* must be called to take effect + * + * \* You likely need to call [UnifiedPushUtils.registerCurrentPushConfiguration], which will call + * pushRegistrationToServer if needed + * + * @param accountManager + */ public static void reinitKeys(final UserAccountManager accountManager) { Context context = MainApp.getAppContext(); Account[] accounts = accountManager.getAccounts(); for (Account account : accounts) { - deleteRegistrationForAccount(account); + deleteRegistrationForAccount(account, true); } String keyPath = context.getDir("nc-keypair", Context.MODE_PRIVATE).getAbsolutePath(); @@ -352,7 +405,6 @@ public static void reinitKeys(final UserAccountManager accountManager) { AppPreferences preferences = AppPreferencesImpl.fromContext(context); String pushToken = preferences.getPushToken(); - pushRegistrationToServer(accountManager, pushToken); preferences.setKeysReInitEnabled(); } diff --git a/app/src/huawei/java/com/owncloud/android/utils/PushUtils.java b/app/src/huawei/java/com/owncloud/android/utils/PushUtils.java index bf1949a33a72..e76994bdd00a 100644 --- a/app/src/huawei/java/com/owncloud/android/utils/PushUtils.java +++ b/app/src/huawei/java/com/owncloud/android/utils/PushUtils.java @@ -7,6 +7,7 @@ */ package com.owncloud.android.utils; +import android.accounts.Account; import android.content.Context; import com.nextcloud.client.account.UserAccountManager; @@ -22,6 +23,10 @@ public final class PushUtils { private PushUtils() { } + public static void setRegistrationForAccountEnabled(Account account, Boolean enabled) { + // do nothing + } + public static void pushRegistrationToServer(final UserAccountManager accountManager, final String pushToken) { // do nothing } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 20c10607b707..543fb6c985ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -661,6 +661,14 @@ android:enabled="true" android:exported="true" tools:ignore="ExportedService" /> + + + + + = ArrayList() diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index 8b6012c0e8db..13b79210cb8f 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -91,6 +91,7 @@ class BackgroundJobFactory @Inject constructor( OfflineSyncWork::class -> createOfflineSyncWork(context, workerParameters) MediaFoldersDetectionWork::class -> createMediaFoldersDetectionWork(context, workerParameters) NotificationWork::class -> createNotificationWork(context, workerParameters) + UnifiedPushWork::class -> createUnifiedPushWork(context, workerParameters) AccountRemovalWork::class -> createAccountRemovalWork(context, workerParameters) CalendarBackupWork::class -> createCalendarBackupWork(context, workerParameters) CalendarImportWork::class -> createCalendarImportWork(context, workerParameters) @@ -215,6 +216,14 @@ class BackgroundJobFactory @Inject constructor( viewThemeUtils.get() ) + private fun createUnifiedPushWork(context: Context, params: WorkerParameters): UnifiedPushWork = UnifiedPushWork( + context, + params, + accountManager, + preferences, + viewThemeUtils.get() + ) + private fun createAccountRemovalWork(context: Context, params: WorkerParameters): AccountRemovalWork = AccountRemovalWork( context, diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt index 0dfef42aba27..90ebeab3ef09 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -134,6 +134,12 @@ interface BackgroundJobManager { fun startMediaFoldersDetectionJob() fun startNotificationJob(subject: String, signature: String) + fun startDecryptedNotificationJob(accountName: String, message: String) + fun registerWebPush(accountName: String, url: String, uaPublicKey: String, auth: String) + fun activateWebPush(accountName: String, token: String) + fun unregisterWebPush(accountName: String) + fun mayResetUnifiedPush() + fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean) fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) fun getFileUploads(user: User): LiveData> diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt index c5f6ceb021d4..6ec474e6122e 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -85,6 +85,7 @@ internal class BackgroundJobManagerImpl( const val JOB_PERIODIC_MEDIA_FOLDER_DETECTION = "periodic_media_folder_detection" const val JOB_IMMEDIATE_MEDIA_FOLDER_DETECTION = "immediate_media_folder_detection" const val JOB_NOTIFICATION = "notification" + const val JOB_UNIFIEDPUSH = "unifiedpush" const val JOB_ACCOUNT_REMOVAL = "account_removal" const val JOB_FILES_UPLOAD = "files_upload" const val JOB_FOLDER_DOWNLOAD = "folder_download" @@ -106,6 +107,7 @@ internal class BackgroundJobManagerImpl( const val TAG_PREFIX_USER = "user" const val TAG_PREFIX_CLASS = "class" const val TAG_PREFIX_START_TIMESTAMP = "timestamp" + const val UNIQUE_TAG_UNIFIEDPUSH = "unifiedpush.uniqueTag" val PREFIXES = setOf(TAG_PREFIX_NAME, TAG_PREFIX_USER, TAG_PREFIX_START_TIMESTAMP, TAG_PREFIX_CLASS) const val NOT_SET_VALUE = "not set" const val PERIODIC_BACKUP_INTERVAL_MINUTES = 24 * 60L @@ -594,6 +596,84 @@ internal class BackgroundJobManagerImpl( workManager.enqueue(request) } + override fun startDecryptedNotificationJob(accountName: String, message: String) { + val data = Data.Builder() + .putString(NotificationWork.KEY_NOTIFICATION_ACCOUNT, accountName) + .putString(NotificationWork.KEY_NOTIFICATION_DECRYPTED_MSG, message) + .build() + + val request = oneTimeRequestBuilder(NotificationWork::class, JOB_NOTIFICATION) + .setInputData(data) + .build() + + workManager.enqueue(request) + } + + override fun registerWebPush(accountName: String, url: String, uaPublicKey: String, auth: String) { + val data = Data.Builder() + .putString(UnifiedPushWork.ACTION, UnifiedPushWork.ACTION_REGISTER) + .putString(UnifiedPushWork.EXTRA_ACCOUNT, accountName) + .putString(UnifiedPushWork.EXTRA_URL, url) + .putString(UnifiedPushWork.EXTRA_UA_PUBKEY, uaPublicKey) + .putString(UnifiedPushWork.EXTRA_AUTH, auth) + .build() + + val request = oneTimeRequestBuilder(UnifiedPushWork::class, JOB_UNIFIEDPUSH) + .setInputData(data) + .build() + + workManager.enqueue(request) + } + + override fun activateWebPush(accountName: String, token: String) { + val data = Data.Builder() + .putString(UnifiedPushWork.ACTION, UnifiedPushWork.ACTION_ACTIVATE) + .putString(UnifiedPushWork.EXTRA_ACCOUNT, accountName) + .putString(UnifiedPushWork.EXTRA_TOKEN, token) + .build() + + val request = oneTimeRequestBuilder(UnifiedPushWork::class, JOB_UNIFIEDPUSH) + .setInputData(data) + .build() + + workManager.enqueue(request) + } + + override fun unregisterWebPush(accountName: String) { + val data = Data.Builder() + .putString(UnifiedPushWork.ACTION, UnifiedPushWork.ACTION_UNREGISTER) + .putString(UnifiedPushWork.EXTRA_ACCOUNT, accountName) + .build() + + val request = oneTimeRequestBuilder(UnifiedPushWork::class, JOB_UNIFIEDPUSH) + .setInputData(data) + .build() + + workManager.enqueue(request) + } + + /** + * Schedule a unique job, for all accounts, to either reconnect to the distributor + * or show a notification to open the app + */ + override fun mayResetUnifiedPush() { + val data = Data.Builder() + .putString(UnifiedPushWork.ACTION, UnifiedPushWork.ACTION_MAY_RESET) + .build() + + val work = oneTimeRequestBuilder(UnifiedPushWork::class, JOB_UNIFIEDPUSH) + .setInitialDelay(10, TimeUnit.SECONDS) + .setInputData(data) + .build() + + workManager.enqueueUniqueWork( + UNIQUE_TAG_UNIFIEDPUSH, + ExistingWorkPolicy.REPLACE, + work + ) + + } + override fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean) { val data = Data.Builder() .putString(AccountRemovalWork.ACCOUNT, accountName) diff --git a/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt b/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt index f95e110ff491..bfd78461c3dd 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt @@ -71,6 +71,7 @@ class NotificationWork constructor( companion object { const val TAG = "NotificationJob" const val KEY_NOTIFICATION_ACCOUNT = "KEY_NOTIFICATION_ACCOUNT" + const val KEY_NOTIFICATION_DECRYPTED_MSG = "KEY_NOTIFICATION_DECRYPTED_MSG" const val KEY_NOTIFICATION_SUBJECT = "subject" const val KEY_NOTIFICATION_SIGNATURE = "signature" private const val KEY_NOTIFICATION_ACTION_LINK = "KEY_NOTIFICATION_ACTION_LINK" @@ -81,9 +82,23 @@ class NotificationWork constructor( @Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "ComplexMethod", "LongMethod") // legacy code override fun doWork(): Result { + val decryptedMsg = inputData.getString(KEY_NOTIFICATION_DECRYPTED_MSG) val subject = inputData.getString(KEY_NOTIFICATION_SUBJECT) ?: "" val signature = inputData.getString(KEY_NOTIFICATION_SIGNATURE) ?: "" - if (!TextUtils.isEmpty(subject) && !TextUtils.isEmpty(signature)) { + if (decryptedMsg != null) { + try { + val accountName = inputData.getString(KEY_NOTIFICATION_ACCOUNT) + accountName + ?: Log_OC.w(TAG, "Trying to work with a decrypted push notification without account") + val decryptedPushMessage = Gson().fromJson( + decryptedMsg, + DecryptedPushMessage::class.java + ) + handlePushMessage(accountName, decryptedPushMessage) + } catch (exception: Exception) { + Log_OC.e(TAG, "Something went very wrong" + exception.localizedMessage) + } + } else if (!TextUtils.isEmpty(subject) && !TextUtils.isEmpty(signature)) { try { val base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT) val base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT) @@ -104,15 +119,7 @@ class NotificationWork constructor( String(decryptedSubject), DecryptedPushMessage::class.java ) - if (decryptedPushMessage.delete) { - notificationManager.cancel(decryptedPushMessage.nid) - } else if (decryptedPushMessage.deleteAll) { - notificationManager.cancelAll() - } else { - val user = accountManager.getUser(signatureVerification.account?.name) - .orElseThrow { RuntimeException() } - fetchCompleteNotification(user, decryptedPushMessage) - } + handlePushMessage(signatureVerification.account?.name, decryptedPushMessage) } } catch (e1: GeneralSecurityException) { Log_OC.d(TAG, "Error decrypting message ${e1.javaClass.name} ${e1.localizedMessage}") @@ -124,6 +131,22 @@ class NotificationWork constructor( return Result.success() } + private fun handlePushMessage(accountName: String?, decryptedPushMessage: DecryptedPushMessage) { + if (decryptedPushMessage.delete) { + notificationManager.cancel(decryptedPushMessage.nid) + } else if (decryptedPushMessage.deleteMultiple) { + decryptedPushMessage.nids.forEach { + notificationManager.cancel(it) + } + } else if (decryptedPushMessage.deleteAll) { + notificationManager.cancelAll() + } else { + val user = accountManager.getUser(accountName) + .orElseThrow { RuntimeException() } + fetchCompleteNotification(user, decryptedPushMessage) + } + } + @Suppress("LongMethod") // legacy code private fun sendNotification(notification: Notification, user: User) { val randomId = SecureRandom() diff --git a/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt b/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt new file mode 100644 index 000000000000..52867bc4375e --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt @@ -0,0 +1,192 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs + +import android.Manifest +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.R +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.notifications.ActivateWebPushRegistrationOperation +import com.owncloud.android.lib.resources.notifications.RegisterAccountDeviceForWebPushOperation +import com.owncloud.android.lib.resources.notifications.UnregisterAccountDeviceForWebPushOperation +import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.notifications.NotificationUtils +import com.owncloud.android.utils.CommonPushUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.ResolvedDistributor +import java.security.SecureRandom + +class UnifiedPushWork( + private val context: Context, + params: WorkerParameters, + private val accountManager: UserAccountManager, + private val preferences: AppPreferences, + private val viewThemeUtils: ViewThemeUtils +) : Worker(context, params) { + override fun doWork(): Result { + when (val action = inputData.getString(ACTION)) { + ACTION_ACTIVATE -> activate() + ACTION_REGISTER -> register() + ACTION_UNREGISTER -> unregister() + ACTION_MAY_RESET -> mayResetUnifiedPush() + else -> Log.w(TAG, "Unknown action $action") + } + return Result.success() + } + + private fun register() { + val url = inputData.getString(EXTRA_URL) ?: run { + Log.w(TAG, "No url supplied") + return + } + val accountName = inputData.getString(EXTRA_ACCOUNT) ?: run { + Log.w(TAG, "No account supplied") + return + } + val uaPublicKey = inputData.getString(EXTRA_UA_PUBKEY) ?: run { + Log.w(TAG, "No uaPubkey supplied") + return + } + val auth = inputData.getString(EXTRA_AUTH) ?: run { + Log.w(TAG, "No auth supplied") + return + } + val ocAccount = OwnCloudAccount(accountManager.getAccountByName(accountName), context) + val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, context) + RegisterAccountDeviceForWebPushOperation( + endpoint = url, + auth = auth, + uaPublicKey = uaPublicKey, + appTypes = appTypes() + ).execute(mClient) + } + + + private fun appTypes(): List = context.packageManager + .getLaunchIntentForPackage(APP_NEXTCLOUD_TALK)?.let { + listOf("all", "-talk") + } ?: listOf("all") + + private fun activate() { + val accountName = inputData.getString(EXTRA_ACCOUNT) ?: run { + Log.w(TAG, "No account supplied") + return + } + val token = inputData.getString(EXTRA_TOKEN) ?: run { + Log.w(TAG, "No account supplied") + return + } + val ocAccount = OwnCloudAccount(accountManager.getAccountByName(accountName), context) + val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, context) + ActivateWebPushRegistrationOperation(token) + .execute(mClient) + } + + private fun unregister() { + val accountName = inputData.getString(EXTRA_ACCOUNT) ?: run { + Log.w(TAG, "No account supplied") + return + } + val ocAccount = OwnCloudAccount(accountManager.getAccountByName(accountName), context) + val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, context) + UnregisterAccountDeviceForWebPushOperation() + .execute(mClient) + } + + /** + * We received one or many unregistration 10 seconds ago: + * - If we are still registered to the distributor, we re-register to it + * - If we still have a default distributor, we register to it + * - Else we show a notification to ask the user to open the application + * so notifications can be reset + */ + fun mayResetUnifiedPush() { + UnifiedPush.getAckDistributor(context)?.let { + Log.d(TAG, "Ack distributor still available") + CommonPushUtils.registerUnifiedPushForAllAccounts(context, accountManager, preferences.pushToken) + } + when (val res = UnifiedPush.resolveDefaultDistributor(context)) { + is ResolvedDistributor.Found -> { + Log.d(TAG, "Found new distributor default") + UnifiedPush.saveDistributor(context, res.packageName) + CommonPushUtils.registerUnifiedPushForAllAccounts(context, accountManager, preferences.pushToken) + } + ResolvedDistributor.NoneAvailable, + ResolvedDistributor.ToSelect -> { + Log.d(TAG, "No default distributor: $res") + showNotificationToResetPush() + } + } + } + + private fun showNotificationToResetPush() { + val pushNotificationId = SecureRandom().nextInt() + val intent = Intent(context, FileDisplayActivity::class.java).apply { + action = Intent.ACTION_VIEW + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + context, + pushNotificationId, + intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + val notificationBuilder = NotificationCompat.Builder(context, NotificationUtils.NOTIFICATION_CHANNEL_PUSH) + .setSmallIcon(R.drawable.notification_icon) + .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.notification_icon)) + .setShowWhen(true) + .setAutoCancel(true) + .setContentTitle(context.getString(R.string.push_notifications)) + .setContentText(context.getString(R.string.notif_push_unregistered)) + .setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_push_unregistered))) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setContentIntent(pendingIntent) + + viewThemeUtils.androidx.themeNotificationCompatBuilder(context, notificationBuilder) + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Log_OC.w(this, "Missing permission to post notifications") + } else { + val notificationManager = NotificationManagerCompat.from(context) + notificationManager.notify(pushNotificationId, notificationBuilder.build()) + } + } + + companion object { + private const val TAG = "UnifiedPushWork" + const val APP_NEXTCLOUD_TALK = "com.nextcloud.talk2" + const val ACTION = "action" + const val ACTION_ACTIVATE = "action.activate" + const val ACTION_REGISTER = "action.register" + const val ACTION_UNREGISTER = "action.unregister" + const val ACTION_MAY_RESET = "action.mayReset" + const val EXTRA_ACCOUNT = "account" + const val EXTRA_TOKEN = "token" + const val EXTRA_URL = "url" + const val EXTRA_UA_PUBKEY = "uaPubkey" + const val EXTRA_AUTH = "auth" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java index b7d6818ea5a1..27b47f6ef198 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java @@ -63,6 +63,11 @@ default void onDarkThemeModeChanged(DarkMode mode) { boolean isShowHiddenFilesEnabled(); void setShowHiddenFilesEnabled(boolean enabled); + + boolean isPushInitialized(); + + boolean isUnifiedPushEnabled(); + void setUnifiedPushEnabled(boolean enabled); boolean isSortFoldersBeforeFiles(); void setSortFoldersBeforeFiles(boolean enabled); diff --git a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java index a16beaeaaad9..1d5530b03963 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -70,6 +70,7 @@ public final class AppPreferencesImpl implements AppPreferences { private static final String PREF__INSTANT_UPLOADING = "instant_uploading"; private static final String PREF__INSTANT_VIDEO_UPLOADING = "instant_video_uploading"; private static final String PREF__SHOW_HIDDEN_FILES = "show_hidden_files_pref"; + private static final String PREF__ENABLE_UNIFIEDPUSH = "enable_unifiedpush_pref"; private static final String PREF__SORT_FOLDERS_BEFORE_FILES = "sort_folders_before_files"; private static final String PREF__SORT_FAVORITES_FIRST = "sort_favorites_first"; private static final String PREF__SHOW_ECOSYSTEM_APPS = "show_ecosystem_apps"; @@ -234,6 +235,21 @@ public void setShowHiddenFilesEnabled(boolean enabled) { preferences.edit().putBoolean(PREF__SHOW_HIDDEN_FILES, enabled).apply(); } + @Override + public boolean isPushInitialized() { + return preferences.contains(PREF__ENABLE_UNIFIEDPUSH); + } + + @Override + public boolean isUnifiedPushEnabled() { + return preferences.getBoolean(PREF__ENABLE_UNIFIEDPUSH, false); + } + + @Override + public void setUnifiedPushEnabled(boolean enabled) { + preferences.edit().putBoolean(PREF__ENABLE_UNIFIEDPUSH, enabled).apply(); + } + @Override public boolean isSortFoldersBeforeFiles() { return preferences.getBoolean(PREF__SORT_FOLDERS_BEFORE_FILES, true); diff --git a/app/src/main/java/com/owncloud/android/datamodel/DecryptedPushMessage.kt b/app/src/main/java/com/owncloud/android/datamodel/DecryptedPushMessage.kt index 301a14914567..f2974711c494 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/DecryptedPushMessage.kt +++ b/app/src/main/java/com/owncloud/android/datamodel/DecryptedPushMessage.kt @@ -20,7 +20,10 @@ data class DecryptedPushMessage( val subject: String, val id: String, val nid: Int, + val nids: List, val delete: Boolean, + @SerializedName("delete-multiple") + val deleteMultiple: Boolean, @SerializedName("delete-all") val deleteAll: Boolean ) : Parcelable diff --git a/app/src/main/java/com/owncloud/android/datamodel/PushConfigurationState.java b/app/src/main/java/com/owncloud/android/datamodel/PushConfigurationState.java index 617f703232cb..f27ef49774a8 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/PushConfigurationState.java +++ b/app/src/main/java/com/owncloud/android/datamodel/PushConfigurationState.java @@ -17,6 +17,8 @@ public class PushConfigurationState { public String deviceIdentifierSignature; public String userPublicKey; public boolean shouldBeDeleted; + public boolean shouldBeDisabled = false; + public boolean disabled = false; public PushConfigurationState(String pushToken, String deviceIdentifier, String deviceIdentifierSignature, String userPublicKey, boolean shouldBeDeleted) { this.pushToken = pushToken; diff --git a/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java b/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java index 067727c519a8..7f0a08a2bde5 100644 --- a/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/GetCapabilitiesOperation.java @@ -41,6 +41,7 @@ protected RemoteOperationResult run(OwnCloudClient client) { if (result.isSuccess() && result.getResultData() != null) { // Read data from the result OCCapability capability = result.getResultData(); + capability.setAccountName(storageManager.getUser().getAccountName()); // Save the capabilities into database storageManager.saveCapabilities(capability); diff --git a/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt new file mode 100644 index 000000000000..db18b12cf85b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.services + +import android.util.Log +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.BackgroundJobManager +import dagger.android.AndroidInjection +import org.json.JSONException +import org.json.JSONObject +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage +import javax.inject.Inject + +class UnifiedPushService: PushService() { + @Inject + lateinit var accountManager: UserAccountManager + @Inject + lateinit var backgroundJobManager: BackgroundJobManager + + override fun onCreate() { + super.onCreate() + AndroidInjection.inject(this) + } + + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + Log.d(TAG, "Received new endpoint for $instance") + // No reason to fail with the default key manager + val key = endpoint.pubKeySet ?: return + backgroundJobManager.registerWebPush(instance, endpoint.url,key.pubKey, key.auth) + } + + override fun onMessage(message: PushMessage, instance: String) { + Log.d(TAG, "Received new message for $instance") + try { + val mObj = JSONObject(message.content.toString(Charsets.UTF_8)) + val token = mObj.getString("activationToken") + backgroundJobManager.activateWebPush(instance, token) + } catch (_: JSONException) { + // Messages are encrypted following RFC8291, and UnifiedPush lib handle the decryption itself: + // message.content is the cleartext + backgroundJobManager.startDecryptedNotificationJob(instance, message.content.toString(Charsets.UTF_8)) + } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + Log.d(TAG, "Registration failed for $instance: $reason") + } + + override fun onUnregistered(instance: String) { + Log.d(TAG, "Unregistered: $instance") + backgroundJobManager.unregisterWebPush(instance) + backgroundJobManager.mayResetUnifiedPush() + } + + companion object { + const val TAG = "UnifiedPushService" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 651f4741639d..96798ed91567 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -155,6 +155,7 @@ import com.owncloud.android.utils.PermissionUtil.requestNotificationPermission import com.owncloud.android.utils.PermissionUtil.requestStoragePermissionIfNeeded import com.owncloud.android.utils.PushUtils import com.owncloud.android.utils.StringUtils +import com.owncloud.android.utils.CommonPushUtils import com.owncloud.android.utils.theme.CapabilityUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -2769,9 +2770,8 @@ class FileDisplayActivity : fun onMessageEvent(event: TokenPushEvent?) { if (!preferences.isKeysReInitEnabled()) { PushUtils.reinitKeys(userAccountManager) - } else { - PushUtils.pushRegistrationToServer(userAccountManager, preferences.getPushToken()) } + CommonPushUtils.registerCurrentPushConfiguration(this, userAccountManager, preferences) } public override fun onStart() { diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java index 65e70c062977..2534b5e017db 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java @@ -77,9 +77,12 @@ import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.MimeTypeUtil; import com.owncloud.android.utils.PermissionUtil; +import com.owncloud.android.utils.CommonPushUtils; import com.owncloud.android.utils.theme.CapabilityUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; +import org.unifiedpush.android.connector.UnifiedPush; + import java.util.ArrayList; import java.util.Objects; @@ -183,6 +186,9 @@ public void onCreate(Bundle savedInstanceState) { // Sync setupSyncCategory(); + // Push notifications + setupPushCategory(preferenceScreen); + // More setupMoreCategory(); @@ -353,6 +359,73 @@ private void setupSyncCategory() { setupAllFilesAccessPreference(preferenceCategorySync); } + private void setupPushCategory(PreferenceScreen preferenceScreen) { + final PreferenceCategory preferenceCategoryPush = (PreferenceCategory) findPreference("push"); + viewThemeUtils.files.themePreferenceCategory(preferenceCategoryPush); + + boolean fUnifiedPushEnabled = getResources().getBoolean(R.bool.unifiedpush_enabled); + boolean supportsWebPush = accountManager.getAllUsers() + .stream() + .anyMatch(u -> CapabilityUtils.getCapability(u, this).getSupportsWebPush().isTrue()); + int nPushServices = UnifiedPush.getDistributors(this).size(); + if (!fUnifiedPushEnabled || !supportsWebPush || nPushServices == 0) { + preferenceScreen.removePreference(preferenceCategoryPush); + } else { + setUnifiedPushPreference(nPushServices > 1); + } + } + + private void setUnifiedPushPreference(boolean canChangeService) { + boolean unifiedPushEnabled = preferences.isUnifiedPushEnabled(); + ThemeableSwitchPreference prefUnifiedPush = (ThemeableSwitchPreference) findPreference("enable_unifiedpush"); + Preference prefChangeService = findPreference("change_unifiedpush"); + + prefUnifiedPush.setChecked(unifiedPushEnabled); + prefUnifiedPush.setOnPreferenceClickListener(preference -> { + if (prefChangeService != null) { + prefChangeService.setEnabled(prefUnifiedPush.isChecked()); + // We cant make it Gone... so we inform it is disabled + prefChangeService.setSummary(getResources().getString(R.string.prefs_disabled_push_system_summary)); + } + preferences.setUnifiedPushEnabled(prefUnifiedPush.isChecked()); + if (prefUnifiedPush.isChecked()) { + CommonPushUtils.tryUseUnifiedPush(this, accountManager, preferences, service -> { + if (service != null) { + prefChangeService.setSummary(Objects.requireNonNullElse(service, "")); + } else { + prefUnifiedPush.setChecked(false); + prefChangeService.setEnabled(false); + } + return null; + }); + } else { + CommonPushUtils.disableUnifiedPush(this, accountManager, preferences.getPushToken()); + } + return false; + }); + + if (canChangeService) { + if (unifiedPushEnabled) { + String service = UnifiedPush.getAckDistributor(this); + prefChangeService.setSummary(Objects.requireNonNullElse(service, "")); + } else { + prefChangeService.setSummary(getResources().getString(R.string.prefs_disabled_push_system_summary)); + } + prefChangeService.setEnabled(unifiedPushEnabled); + prefChangeService.setOnPreferenceClickListener(preference -> { + CommonPushUtils.pickUnifiedPushDistributor(this, accountManager, preferences.getPushToken(), service -> { + if (service != null) { + prefChangeService.setSummary(service); + } + return null; + }); + return false; + }); + } else { + prefChangeService.getParent().removePreference(prefChangeService); + } + } + private void setupMoreCategory() { final PreferenceCategory preferenceCategoryMore = (PreferenceCategory) findPreference("more"); viewThemeUtils.files.themePreferenceCategory(preferenceCategoryMore); diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java index dd137e4f9581..7760a428b658 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/UserInfoActivity.java @@ -47,7 +47,7 @@ import com.owncloud.android.ui.dialog.AccountRemovalDialog; import com.owncloud.android.ui.events.TokenPushEvent; import com.owncloud.android.utils.DisplayUtils; -import com.owncloud.android.utils.PushUtils; +import com.owncloud.android.utils.CommonPushUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; import org.greenrobot.eventbus.Subscribe; @@ -359,7 +359,7 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { @Subscribe(threadMode = ThreadMode.BACKGROUND) public void onMessageEvent(TokenPushEvent event) { - PushUtils.pushRegistrationToServer(getUserAccountManager(), preferences.getPushToken()); + CommonPushUtils.registerCurrentPushConfiguration(this, getUserAccountManager(), preferences); } diff --git a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt new file mode 100644 index 000000000000..9b530decf16f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt @@ -0,0 +1,284 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.utils + +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.preferences.AppPreferences +import com.owncloud.android.BuildConfig +import com.owncloud.android.R +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.notifications.GetVAPIDOperation +import com.owncloud.android.lib.resources.notifications.UnregisterAccountDeviceForWebPushOperation +import com.owncloud.android.utils.theme.CapabilityUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.ResolvedDistributor + +/** + * Handle UnifiedPush (web push server side) and proxy push ([PushUtils]) registrations + */ +object CommonPushUtils { + private val TAG: String = CommonPushUtils::class.java.getSimpleName() + + /** + * Register UnifiedPush, or FCM with the current config + * + * This is run when the application starts, and this is also where push notifications + * are set up for the first time + * + * Push notifications are set up for the first time with this function + */ + @JvmStatic + fun registerCurrentPushConfiguration(activity: Activity, accountManager: UserAccountManager, preferences: AppPreferences) { + if ( + (!preferences.isPushInitialized && BuildConfig.DEFAULT_PUSH_UNIFIEDPUSH) + || preferences.isUnifiedPushEnabled + ){ + tryUseUnifiedPush(activity, accountManager, preferences) {} + } else { + CoroutineScope(Dispatchers.IO).launch { + PushUtils.pushRegistrationToServer(accountManager, preferences.pushToken) + } + } + } + + /** + * Check if server supports web push + */ + private fun supportsWebPush(context: Context, accountManager: UserAccountManager, accountName: String): Boolean = + accountManager.getUser(accountName) + .map { CapabilityUtils.getCapability(it, context).supportsWebPush.isTrue } + .also { Log_OC.d(TAG, "Found push capability: $it") } + .orElse(false) + + /** + * Use default distributor, register all accounts that support webpush + * + * Unregister proxy push for account if succeed + * Re-register proxy push for the others + * + * @param activity: Context needs to be an activity, to get a result + * @param accountManager: Used to register all accounts + * @param callback: run with the push service name if available + */ + @JvmStatic + fun tryUseUnifiedPush( + activity: Activity, + accountManager: UserAccountManager, + preferences: AppPreferences, + callback: (String?) -> Unit + ) { + UnifiedPush.getAckDistributor(activity)?.let { + Log_OC.d(TAG, "Found ack distributor") + registerUnifiedPushForAllAccounts(activity, accountManager, preferences.pushToken) + callback(it) + return + } + when (val res = UnifiedPush.resolveDefaultDistributor(activity)) { + is ResolvedDistributor.Found -> { + Log_OC.d(TAG, "Found default distributor") + preferences.isUnifiedPushEnabled = true + UnifiedPush.saveDistributor(activity, res.packageName) + registerUnifiedPushForAllAccounts(activity, accountManager, preferences.pushToken) + callback(res.packageName) + } + ResolvedDistributor.NoneAvailable -> { + Log_OC.d(TAG, "No default distributor") + // Do not change preference + disableUnifiedPush(activity, accountManager, preferences.pushToken) + callback(null) + } + ResolvedDistributor.ToSelect -> { + Log_OC.d(TAG, "Default distributor to select") + activity.runOnUiThread { + showDistributorSelectionDialog(activity) { confirmed -> + if (confirmed) { + UnifiedPush.tryUseDefaultDistributor(activity) { res -> + if (res) { + preferences.isUnifiedPushEnabled = true + registerUnifiedPushForAllAccounts(activity, accountManager, preferences.pushToken) + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + preferences.isUnifiedPushEnabled = false + disableUnifiedPush(activity, accountManager, preferences.pushToken) + callback(null) + } + } + } else { + Log_OC.d(TAG, "Default distributor dismissed") + preferences.isUnifiedPushEnabled = false + disableUnifiedPush(activity, accountManager, preferences.pushToken) + callback(null) + } + } + } + } + } + } + + /** + * Inform the user they will have to select a distributor + * + * **Should nearly never happen** + * + * It is shown only if the user has many distributors, they haven't set a default yet, nor selected a distributor + */ + private fun showDistributorSelectionDialog(context: Context, onResult: (Boolean) -> Unit) { + MaterialAlertDialogBuilder(context, R.style.Theme_ownCloud_Dialog) + .setTitle(context.getString(R.string.unifiedpush)) + .setMessage(context.getString(R.string.select_unifiedpush_service_dialog)) + .setPositiveButton( + android.R.string.ok + ) { dialog: DialogInterface?, _: Int -> + dialog?.dismiss() + onResult(true) + } + .setNegativeButton( + android.R.string.cancel + ) { dialog: DialogInterface?, _: Int -> + dialog?.dismiss() + onResult(false) + } + .create() + .show() + } + + /** + * Pick another distributor, register all accounts that support webpush + * + * Unregister proxy push for account if succeed + * Re-register proxy push for the others + * + * @param activity: Context needs to be an activity, to get a result + * @param accountManager: Used to register all accounts + * @param callback: run with the push service name if available + */ + @JvmStatic + fun pickUnifiedPushDistributor( + activity: Activity, + accountManager: UserAccountManager, + proxyPushToken: String?, + callback: (String?) -> Unit + ) { + Log_OC.d(TAG, "Picking another UnifiedPush distributor") + UnifiedPush.tryPickDistributor(activity as Context) { res -> + if (res) { + registerUnifiedPushForAllAccounts(activity, accountManager, proxyPushToken) + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + callback(null) + } + } + } + + /** + * Disable UnifiedPush and try to register with proxy push again + */ + @JvmStatic + fun disableUnifiedPush( + context: Context, + accountManager: UserAccountManager, + proxyPushToken: String? + ) { + CoroutineScope(Dispatchers.IO).launch { + for (account in accountManager.getAccounts()) { + PushUtils.setRegistrationForAccountEnabled(account, true) + unregisterUnifiedPushForAccount(context, accountManager, OwnCloudAccount(account, context)) + } + PushUtils.pushRegistrationToServer(accountManager, proxyPushToken) + } + } + + @JvmStatic + fun unregisterUnifiedPushForAccount( + context: Context, + accountManager: UserAccountManager, + account: OwnCloudAccount + ) { + CoroutineScope(Dispatchers.IO).launch { + if (supportsWebPush(context, accountManager, account.name)) { + val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(account, context) + UnregisterAccountDeviceForWebPushOperation() + .execute(mClient) + UnifiedPush.unregister(context, account.name) + } + } + } + + /** + * Register UnifiedPush for all accounts with the server VAPID key if the server supports web push + * + * Web push is registered on the nc server when the push endpoint is received + * + * Proxy push is unregistered for accounts on server with web push support, if a server doesn't support web push, proxy push is re-registered + */ + fun registerUnifiedPushForAllAccounts( + context: Context, + accountManager: UserAccountManager, + proxyPushToken: String? + ) { + CoroutineScope(Dispatchers.IO).launch { + val jobs = accountManager.accounts.map { account -> + CoroutineScope(Dispatchers.IO).launch { + val ocAccount = OwnCloudAccount(account, context) + val res = registerUnifiedPushForAccount(context, accountManager, ocAccount) + if (res) { + PushUtils.setRegistrationForAccountEnabled(account, false) + } + } + } + jobs.joinAll() + proxyPushToken?.let { + PushUtils.pushRegistrationToServer(accountManager, it) + } + } + } + + /** + * Register UnifiedPush with the server VAPID key if the server supports web push + * + * Web push is registered on the nc server when the push endpoint is received + * + * @return true if registration succeed + */ + private fun registerUnifiedPushForAccount( + context: Context, + accountManager: UserAccountManager, + account: OwnCloudAccount + ): Boolean { + Log_OC.d(TAG, "Registering web push for ${account.name}") + if (supportsWebPush(context, accountManager, account.name)) { + val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(account, context) + val vapidRes = GetVAPIDOperation().execute(mClient) + if (vapidRes.isSuccess) { + val vapid = vapidRes.resultData.vapid + UnifiedPush.register( + context, + instance = account.name, + messageForDistributor = account.name, + vapid = vapid + ) + } else { + Log_OC.w(TAG, "Couldn't find VAPID for ${account.name}") + } + return vapidRes.isSuccess + } else { + Log_OC.d(TAG, "${account.name}'s server doesn't support web push: aborting.") + return false + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/setup.xml b/app/src/main/res/values/setup.xml index afed2dafe23a..b397d40ba30e 100644 --- a/app/src/main/res/values/setup.xml +++ b/app/src/main/res/values/setup.xml @@ -99,6 +99,7 @@ "https://play.google.com/store/apps/details?id=com.nextcloud.client" https://nextcloud.com/install + true false diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dee81fb3f3e0..fc8ac788f266 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1504,4 +1504,14 @@ File upload conflicts Upload conflicts detected. Open uploads to resolve. Resolve conflicts + + Push notifications + Receive notifications with + Enable UnifiedPush + Receive push notifications with an external UnifiedPush service + Enable UnifiedPush first + UnifiedPush + You are about to select your default push service + Push notifications + Unregistered from UnifiedPush, please open the app to reset push notifications diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index ff17922ff1f8..b7844490edd7 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -86,7 +86,21 @@ android:summary="@string/prefs_all_files_access_summary" /> - + + + + + + + @@ -296,6 +297,7 @@ + diff --git a/settings.gradle.kts b/settings.gradle.kts index 3583331170f3..e4e1f99e3f53 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,7 +36,11 @@ dependencyResolutionManagement { } } mavenCentral() - maven("https://jitpack.io") + maven("https://jitpack.io") { + content { + includeGroupByRegex("com\\.github\\..*") + } + } } } // includeBuild("../android-common") {