diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a659e78e0037..334ecd95c6f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -147,16 +147,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") { @@ -164,6 +167,7 @@ android { dimension = "default" versionCode = 20220322 versionName = "20220322" + buildConfigField("boolean", "DEFAULT_PUSH_UNIFIEDPUSH", "false") } register("qa") { @@ -171,6 +175,7 @@ android { dimension = "default" versionCode = 1 versionName = "1" + buildConfigField("boolean", "DEFAULT_PUSH_UNIFIEDPUSH", "false") } } } @@ -512,6 +517,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 cbb2adc5acc8..6ebe0ce458e4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -654,6 +654,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 b9145d7e8769..510847b2aa1f 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -94,6 +94,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) @@ -223,6 +224,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 a859e5808d56..0c176aff2d41 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -7,10 +7,12 @@ package com.nextcloud.client.jobs import androidx.lifecycle.LiveData +import androidx.work.Data import androidx.work.ListenableWorker import com.nextcloud.client.account.User import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.datamodel.WebPushJobData import com.owncloud.android.operations.DownloadType /** @@ -130,6 +132,16 @@ interface BackgroundJobManager { fun startMediaFoldersDetectionJob() fun startNotificationJob(subject: String, signature: String) + fun startDecryptedNotificationJob(accountName: String, message: String) + + fun startWebPushJob(jobData: WebPushJobData) + + /** + * Schedule a unique job, for all accounts, to either reconnect to the distributor + * or show a notification to open the app + */ + fun mayResetUnifiedPush() + fun startAccountRemovalJob(accountName: String, remoteWipe: Boolean) fun startFilesUploadJob( user: User, 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 4a28b1715dea..206565641c3f 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -40,6 +40,7 @@ import com.nextcloud.client.preferences.AppPreferences import com.nextcloud.utils.extensions.isWorkScheduled import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.SyncedFolder +import com.owncloud.android.datamodel.WebPushJobData import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.DownloadType import kotlinx.coroutines.CoroutineScope @@ -89,6 +90,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" @@ -110,6 +112,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 @@ -117,6 +120,7 @@ internal class BackgroundJobManagerImpl( const val OFFLINE_OPERATIONS_PERIODIC_JOB_INTERVAL_MINUTES = 5L const val DEFAULT_IMMEDIATE_JOB_DELAY_SEC = 3L const val DEFAULT_BACKOFF_CRITERIA_DELAY_SEC = 300L + const val UNIFIEDPUSH_WORK_DELAY_SEC = 10L private const val KEEP_LOG_MILLIS = 1000 * 60 * 60 * 24 * 3L @@ -592,6 +596,49 @@ 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 startWebPushJob(jobData: WebPushJobData) { + val request = oneTimeRequestBuilder(UnifiedPushWork::class, JOB_UNIFIEDPUSH) + .setInputData(jobData.inputData) + .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(UNIFIEDPUSH_WORK_DELAY_SEC, 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 928fe29e08ad..7efa82f016b0 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt @@ -73,6 +73,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" @@ -83,9 +84,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}") @@ -135,6 +142,22 @@ class NotificationWork constructor( cipher.doFinal(base64DecodedSubject) } + private fun handlePushMessage(accountName: String?, decryptedPushMessage: DecryptedPushMessage) = when { + decryptedPushMessage.delete -> + notificationManager.cancel(decryptedPushMessage.nid) + decryptedPushMessage.deleteMultiple -> + decryptedPushMessage.nids.forEach { + notificationManager.cancel(it) + } + decryptedPushMessage.deleteAll -> + notificationManager.cancelAll() + else -> + fetchCompleteNotification( + accountManager.getUser(accountName).orElseThrow { RuntimeException() }, + 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..b2d90f4607d7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt @@ -0,0 +1,196 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Simon Gougeon + * 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.nextcloud.common.NextcloudClient +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 ncClient(accountName: String): NextcloudClient { + val ocAccount = OwnCloudAccount(accountManager.getAccountByName(accountName), context) + return OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, context) + } + + @Suppress("ReturnCount") + 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 mClient = ncClient(accountName) + 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 mClient = ncClient(accountName) + ActivateWebPushRegistrationOperation(token) + .execute(mClient) + } + + private fun unregister() { + val accountName = inputData.getString(EXTRA_ACCOUNT) ?: run { + Log.w(TAG, "No account supplied") + return + } + val mClient = ncClient(accountName) + 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" + } +} 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 4f4e85599480..75138b87ad42 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java @@ -64,6 +64,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 709f368080d8..e9c89c442551 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -71,6 +71,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/nextcloud/utils/TimeoutScope.kt b/app/src/main/java/com/nextcloud/utils/TimeoutScope.kt new file mode 100644 index 000000000000..8689bdfc13af --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/TimeoutScope.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.time.withTimeout +import java.time.Duration +import kotlin.coroutines.CoroutineContext + +/** + * Define a Global Scope for jobs not tied to activities, + * with a timeout to avoid resource leak + */ +class TimeoutScope(val timeout: Duration, context: CoroutineContext = Dispatchers.Default) { + private val scope = CoroutineScope(context) + fun launch(block: suspend CoroutineScope.() -> Unit): Job = scope.launch { + withTimeout(timeout, block) + } +} \ No newline at end of file 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/datamodel/WebPushJobData.kt b/app/src/main/java/com/owncloud/android/datamodel/WebPushJobData.kt new file mode 100644 index 000000000000..59c14a804e93 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/datamodel/WebPushJobData.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.datamodel + +import androidx.work.Data +import com.nextcloud.client.jobs.UnifiedPushWork + +sealed class WebPushJobData { + abstract val inputData: Data + + data class Register( + val accountName: String, + val url: String, + val uaPublicKey: String, + val auth: String + ): WebPushJobData() { + override val inputData = 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() + } + data class Activate(val accountName: String, val token: String): WebPushJobData() { + override val inputData = Data.Builder() + .putString(UnifiedPushWork.ACTION, UnifiedPushWork.ACTION_ACTIVATE) + .putString(UnifiedPushWork.EXTRA_ACCOUNT, accountName) + .putString(UnifiedPushWork.EXTRA_TOKEN, token) + .build() + } + data class Unregister(val accountName: String): WebPushJobData() { + override val inputData = Data.Builder() + .putString(UnifiedPushWork.ACTION, UnifiedPushWork.ACTION_UNREGISTER) + .putString(UnifiedPushWork.EXTRA_ACCOUNT, accountName) + .build() + } +} \ No newline at end of file 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..5a20794ba8ca --- /dev/null +++ b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Simon Gougeon + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.services + +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.jobs.BackgroundJobManager +import com.owncloud.android.datamodel.WebPushJobData +import com.owncloud.android.lib.common.utils.Log_OC +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_OC.d(TAG, "Received new endpoint for $instance") + // No reason to fail with the default key manager + val key = endpoint.pubKeySet ?: return + backgroundJobManager.startWebPushJob( + WebPushJobData.Register(instance, endpoint.url,key.pubKey, key.auth) + ) + } + + override fun onMessage(message: PushMessage, instance: String) { + try { + val token = JSONObject(message.content.toString(Charsets.UTF_8)) + .getString("activationToken") + Log_OC.d(TAG, "Received activation push notification for $instance") + backgroundJobManager.startWebPushJob( + WebPushJobData.Activate(instance, token) + ) + } catch (_: JSONException) { + Log_OC.d(TAG, "Received push notification for $instance") + // 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_OC.d(TAG, "Registration failed for $instance: $reason") + } + + override fun onUnregistered(instance: String) { + Log_OC.d(TAG, "Unregistered: $instance") + backgroundJobManager.startWebPushJob(WebPushJobData.Unregister(instance)) + backgroundJobManager.mayResetUnifiedPush() + } + + companion object { + const val TAG = "UnifiedPushService" + } +} 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 1ef105280bb8..a6c79560523b 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 @@ -160,6 +160,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.UriUtils import com.owncloud.android.utils.theme.CapabilityUtils import kotlinx.coroutines.Dispatchers @@ -2935,9 +2936,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 d1093bcb5d93..c1e2b24b82d5 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 @@ -186,6 +186,9 @@ public void onCreate(Bundle savedInstanceState) { // Sync setupSyncCategory(); + // Push notifications + SettingsPushCategory.setup(this, preferenceScreen); + // More setupMoreCategory(); diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SettingsPushCategory.kt b/app/src/main/java/com/owncloud/android/ui/activity/SettingsPushCategory.kt new file mode 100644 index 000000000000..c83a65aad360 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/activity/SettingsPushCategory.kt @@ -0,0 +1,88 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Simon Gougeon + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.activity + +import android.preference.Preference +import android.preference.PreferenceCategory +import android.preference.PreferenceScreen +import com.owncloud.android.R +import com.owncloud.android.ui.ThemeableSwitchPreference +import com.owncloud.android.utils.CommonPushUtils +import com.owncloud.android.utils.theme.CapabilityUtils +import org.unifiedpush.android.connector.UnifiedPush + +object SettingsPushCategory { + @JvmStatic + fun SettingsActivity.setup(preferenceScreen: PreferenceScreen) { + val preferenceCategoryPush = findPreference("push") as PreferenceCategory + viewThemeUtils.files.themePreferenceCategory(preferenceCategoryPush) + + val fUnifiedPushEnabled: Boolean = resources.getBoolean(R.bool.unifiedpush_enabled) + val supportsWebPush: Boolean = accountManager.allUsers.any { u -> + CapabilityUtils.getCapability(u, this).supportsWebPush.isTrue + } + val nPushServices = UnifiedPush.getDistributors(this).size + if (!fUnifiedPushEnabled || !supportsWebPush || nPushServices == 0) { + preferenceScreen.removePreference(preferenceCategoryPush) + } else { + setUnifiedPushPreference(nPushServices > 1) + } + } + + private fun SettingsActivity.setUnifiedPushPreference(canChangeService: Boolean) { + val unifiedPushEnabled: Boolean = preferences.isUnifiedPushEnabled + val prefUnifiedPush = findPreference("enable_unifiedpush") as ThemeableSwitchPreference? + val prefChangeService: Preference? = findPreference("change_unifiedpush") + + prefUnifiedPush?.isChecked = unifiedPushEnabled + prefUnifiedPush?.setOnPreferenceClickListener { _ -> + prefChangeService?.isEnabled = prefUnifiedPush.isChecked + // We cant make it Gone... so we inform it is disabled + prefChangeService?.summary = resources.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?.summary = service + } else { + prefUnifiedPush.isChecked = false + prefChangeService?.isEnabled = false + } + } + } else { + CommonPushUtils.disableUnifiedPush(this, accountManager, preferences.pushToken) + } + false + } + + if (canChangeService) { + if (unifiedPushEnabled) { + val service = UnifiedPush.getAckDistributor(this) + prefChangeService?.summary = service ?: "" + } else { + prefChangeService?.summary = resources.getString(R.string.prefs_disabled_push_system_summary) + } + prefChangeService?.isEnabled = unifiedPushEnabled + prefChangeService?.setOnPreferenceClickListener { _ -> + CommonPushUtils.pickUnifiedPushDistributor( + this, + accountManager, + preferences.getPushToken() + ) { service -> + service?.let { + prefChangeService.summary = service + } + } + false + } + } else { + prefChangeService?.parent?.removePreference(prefChangeService) + } + } +} 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 9743507c85f2..ea7c51ce958c 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 @@ -46,6 +46,7 @@ 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 org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; @@ -368,7 +369,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..0c814c75c1a2 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt @@ -0,0 +1,297 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Simon Gougeon + * 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.nextcloud.utils.TimeoutScope +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.Dispatchers +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.ResolvedDistributor +import java.time.Duration + +/** + * 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 { + TimeoutScope(Duration.ofMinutes(1L), 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? + ) { + TimeoutScope(Duration.ofMinutes(1L), 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 + ) { + TimeoutScope(Duration.ofMinutes(1L), 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? + ) { + TimeoutScope(Duration.ofMinutes(1L), Dispatchers.IO).launch { + supervisorScope { + val jobs = accountManager.accounts.map { account -> + launch { + try { + val ocAccount = OwnCloudAccount(account, context) + val res = registerUnifiedPushForAccount(context, accountManager, ocAccount) + if (res) { + PushUtils.setRegistrationForAccountEnabled(account, false) + } + } catch (e: Exception) { + Log_OC.e(TAG, "Failed to register push for ${account.name}", e) + } + } + } + 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 + } + } +} diff --git a/app/src/main/res/values/setup.xml b/app/src/main/res/values/setup.xml index 7df442ab878b..247345928b7a 100644 --- a/app/src/main/res/values/setup.xml +++ b/app/src/main/res/values/setup.xml @@ -98,6 +98,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 d662ed36f1bf..e13aa8cc470c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1526,4 +1526,14 @@ Upload conflicts detected. Open uploads to resolve. Resolve conflicts Extension cannot be changed + + 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 fb7f89111f0f..c28012c3b9cf 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" /> - + + + + + + + @@ -291,7 +292,10 @@ - + + + + @@ -309,6 +313,7 @@ + @@ -370,6 +375,7 @@ + @@ -20085,6 +20091,11 @@ + + + + + @@ -25606,6 +25617,11 @@ + + + + + @@ -31297,6 +31313,11 @@ + + + + + @@ -31554,6 +31575,7 @@ + @@ -38500,6 +38522,11 @@ + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 651d7a880838..2ff808c1c8b9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,7 +37,11 @@ dependencyResolutionManagement { } } mavenCentral() - maven("https://jitpack.io") + maven("https://jitpack.io") { + content { + includeGroupByRegex("com\\.github\\..*") + } + } } }