From 0a003a417064320358f6e647de64dcd07f9e5109 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 4 Dec 2025 09:02:29 +0100 Subject: [PATCH 01/20] chore(deps): Restrict jitpack content Signed-off-by: sim --- settings.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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") { From 022fb0228095a1886fff94782fcc326ad723301e Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 4 Dec 2025 10:14:38 +0100 Subject: [PATCH 02/20] chore: Fix capability update The capability doesn't have an accountName, and therefore doesn't update the user cache Signed-off-by: sim --- .../owncloud/android/operations/GetCapabilitiesOperation.java | 1 + 1 file changed, 1 insertion(+) 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); From 31f1d8e87bb0e362246c32ab4699109b625dbf0f Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 4 Dec 2025 10:16:59 +0100 Subject: [PATCH 03/20] chore(notif): Support delete-multiple use dedicated function to handle message Signed-off-by: sim --- .../nextcloud/client/jobs/NotificationWork.kt | 26 ++++++++++++------- .../android/datamodel/DecryptedPushMessage.kt | 3 +++ 2 files changed, 20 insertions(+), 9 deletions(-) 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..f58495b87298 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt @@ -104,15 +104,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 +116,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/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 From 7dc6143e821824d521f705d8552a9b200dc254e4 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 4 Dec 2025 10:25:25 +0100 Subject: [PATCH 04/20] feat(unifiedpush): Add UnifiedPush service Signed-off-by: sim --- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 8 ++ .../nextcloud/client/di/ComponentsModule.java | 4 + .../client/jobs/BackgroundJobManager.kt | 2 + .../client/jobs/BackgroundJobManagerImpl.kt | 13 +++ .../nextcloud/client/jobs/NotificationWork.kt | 17 +++- .../android/services/UnifiedPushService.kt | 90 +++++++++++++++++++ .../android/utils/UnifiedPushUtils.kt | 64 +++++++++++++ gradle/libs.versions.toml | 2 + gradle/verification-keyring.keys | 60 +++++++++++++ gradle/verification-metadata.xml | 2 + 11 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt create mode 100644 app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 248f60acfc60..dc72e664d939 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -500,6 +500,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/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" /> + + + + + > 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..47ca2d59b0b9 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -594,6 +594,19 @@ 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 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 f58495b87298..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) 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..49a66d066393 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt @@ -0,0 +1,90 @@ +/* + * 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 com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +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 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 java.util.concurrent.Executors +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 + Executors.newSingleThreadExecutor().execute { + val ocAccount = OwnCloudAccount(accountManager.getAccountByName(instance), this) + val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, this) + RegisterAccountDeviceForWebPushOperation( + endpoint = endpoint.url, + auth = key.auth, + uaPublicKey = key.pubKey, + apptypes = listOf("all") // TODO: remove talk if installed + ).execute(mClient) + } + } + + 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") + Executors.newSingleThreadExecutor().execute { + val ocAccount = OwnCloudAccount(accountManager.getAccountByName(instance), this) + val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, this) + ActivateWebPushRegistrationOperation(token) + .execute(mClient) + } + } 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") + Executors.newSingleThreadExecutor().execute { + val ocAccount = OwnCloudAccount(accountManager.getAccountByName(instance), this) + val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, this) + UnregisterAccountDeviceForWebPushOperation() + .execute(mClient) + } + } + + companion object { + const val TAG = "UnifiedPushService" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt new file mode 100644 index 000000000000..5248bc83ae8c --- /dev/null +++ b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt @@ -0,0 +1,64 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.utils + +import android.accounts.Account +import android.app.Activity +import android.content.Context +import android.util.Log +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.resources.notifications.GetVAPIDOperation +import com.owncloud.android.utils.theme.CapabilityUtils +import org.unifiedpush.android.connector.UnifiedPush +import java.util.concurrent.Executors + +object UnifiedPushUtils { + private val TAG: String = UnifiedPushUtils::class.java.getSimpleName() + + @JvmStatic + fun useDefaultDistributor(activity: Activity, userAccountManager: UserAccountManager) { + Log.d(TAG, "Using default UnifiedPush distrbutor") + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity as Context) { + userAccountManager.accounts.forEach { account -> + Executors.newSingleThreadExecutor().execute { + registerWebPushForAccount(activity, userAccountManager, account) + } + } + } + } + + private fun supportsWebPush(context: Context, userAccountManager: UserAccountManager, account: Account): Boolean = + userAccountManager.getUser(account.name) + .map { CapabilityUtils.getCapability(it, context).supportsWebPush.isTrue } + .also { Log.d(TAG, "Found push capability: $it") } + .orElse(false) + + private fun registerWebPushForAccount(context: Context, userAccountManager: UserAccountManager, account: Account) { + Log.d(TAG, "Registering web push for ${account.name}") + if (supportsWebPush(context, userAccountManager, account)) { + val ocAccount = OwnCloudAccount(account, context) + val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, 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.w(TAG, "Couldn't find VAPID for ${account.name}") + } + } else { + Log.d(TAG, "${account.name}'s server doesn't support web push: aborting.") + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7112ba815312..4c53c4fe4169 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ coilVersion = "2.7.0" commonsHttpclient = "3.1" commonsIoVersion = "2.21.0" composeBom = "2026.02.00" +unifiedPushConnectorVersion = "3.3.0" conscryptAndroidVersion = "2.5.3" constraintlayoutVersion = "2.2.1" coreTestingVersion = "2.2.0" @@ -149,6 +150,7 @@ objenesis = { module = "org.objenesis:objenesis", version.ref = "objenesis" } play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "playServicesBaseVersion" } review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "reviewKtxVersion" } slfj = { module = "org.slf4j:jcl-over-slf4j", version.ref = "slfj" } +unifiedpush-connector = { module = "org.unifiedpush.android:connector", version.ref = "unifiedPushConnectorVersion" } # Mockito mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoVersion" } diff --git a/gradle/verification-keyring.keys b/gradle/verification-keyring.keys index 62683d181b47..7124a520e1ec 100644 --- a/gradle/verification-keyring.keys +++ b/gradle/verification-keyring.keys @@ -8947,3 +8947,63 @@ byp2Ws27l1oNhMexztBivHz1OLxGJY8odrCtuxK3JMllDdln/HHdwrp6h7SDRdxR 6llX74zIdctZVsii8eJHvA== =DZ0u -----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: 7397 6C9C 39C1 479B 84E2 641A 5A68 A224 9128 E2C6 + +xsFNBGPcwwABEADTw/gqmHh4LTSDsBP0KMoXFtFQnv7xmVPPrPjt0NxGn3w2WIou +7UaLUTViKkgm92h72gyM7N9JfNBLcYrqVf9ed75MPdGQgzIhkVg3SLWZGFoIQUJ4 +VznKuqJmd0dSRtApXL9ZoVXf1mLnbLkOvfLfw2hVIsMJcW9/G4It7pPY82IiwTLn +XG/pw6+wLa5FGCM4mldPnyBDR935nSkgnZzQJyDESXZKS1uiU1rMcGWkVLJ1UYfg +fT5c6jAk+75vhyQEvHReoa1T8fgBPD0jAlE7T80460x8dramshhAAIOZLnlAuiBN +A7KPY7cUDxDyFNLdhj7lGjPP1UTv5mdcZc0H6tgaapOB8QzqnkAJN7GrPHjAWnu7 +ujdiT+lmng+waiBfoQN7HQyJXng8Skj1tVYjuAUNgUuA6p1hL30k9Ny9wO2BBg23 +OXYn8yLptZCUg4T31w2ko2PReSxMeEI6S2jWTALP9HH1Q1sinQnlJ8SfPAssG8wn +qjaV4PtS8bO+Gy2NosG389dzibrmVJAHqymTLlaviWgeqAXEwZhbVcSOv+B3JgAX +h1dI2zDJUMGV7jNbKa+UNGb+di8T3J5JEXCNM/Zvm3KNudfZFbcNS1pEzNRzm4gh +kmNHQEtknSm6NHaWIP5eMIxsKGUA6qTR8XE9qrvpwV35rwbxmPHSowHzzwARAQAB +zsFNBGPcwwABEADh/vvuWr2Pl5xD/gF1vKIdy+sNTTadx7EoAsdnrYShtP3jvUPL +9VDvbpyHK9B6wFEulUM037L7BOl9khg9oO4G/NXlU3wiIJk4dI4tBrj1IAFD7z3q +Q2Sgpy/bTsBRZZCwOiW28IxqQsx5DE4i5YYOEjjyqZiza4/I3TchKN0sEOwb77ME +UrYS1CUelpl/zFlYZNGXT/oDjJI2bVs1pvCMtb2iQW7m6JvDEY0xZ7zoRm2rJA56 +7oV3WAO6u2T2tpAzfu1SYJRRPbUt45pdzWSZUzCQwcB0ZTAuQbK4nIsjPGv6oAPm +3PgpEpW2PBHPux+UHN03k/vEpb5XLLTBuWIdgtXRzD2vSkEO7A6CBkYTY4TY5Uod +LgV+szYlG+N00m0h2SOf/9FEijRIA2XMfNYZ6E8x3I0ADXmOEE1MfdBGSEtk/Tzb +/NX8Bb8/zk1tKRI00vEz1bjOTsiRZQ6Aod+hUcCPasTUAKIgTpe30zd1v1krF4le +VRXHWJbUmpsr7CoJRIrKBghkP7K0vLUQzes0djxl/J9C8Ru7bjM6Ndjmy5+oSCMM +PJyJ/Wpd+fEWZxgIukAnjLtuZTUVeMESzP1CkUeNv+aGadPNGw97VRAFEfk6508i +hg+TVEiyFORV/njYUB+4zm/+aczd6KKT24t6DIVdm1FkSds8SpcDT8Ei0wARAQAB +wsF2BBgBCgAgFiEEc5dsnDnBR5uE4mQaWmiiJJEo4sYFAmPcwwACGwwACgkQWmii +JJEo4sbAdRAAlzA7kLCzFnCSYr2TgCfQEoI8yslnPL0flq7ghw5yBK4OdUbYoUBY +BroZMJLwhPvyaEdp3t63Sl/9GsYNfub+TAOJA64WuRtOT1QbOh6+U5T7X5yvPM3F +AGUuYOlk1ABuTAtbOWW+iPOpE7sZaai0j9zH2vPyviBqZ6GtvsuVT7ynRbrYuWe9 +127ZkJet6zPzGXoyTE+FaGOdv/wd+9u1Qjk/lYowNoQ7xXWbnF4jD3o6lM56uOgv +PUFoSnzbsd1fCXcfQ4wj+O3yEoMDVa1K9eIrSz7TrL5K4VzfOxaHxPh2orE8dFgj +Xy0Vm/KCXTOc9DcJXCjqJVh9RoDxTaNkjWfkN+1bq9NUaPauRduMwlkbk904ygXx +MJ485hm5uSaCKM8eYBp4y/CdwOcnonBEg+lS3rVIcfDUByRrim5pOsIlSe3f2OX3 +txMYQvXJivYOA4phKSpntp4TDzGkZfLbCIpaFowR3px/c2LKuQmTmr+Vl8v5W4kB +gQGEDdxYa7jhYx9HbLimtQG2XcCC+javwdubT/ItHrfcAB6B+dV0iRA5b4QDGtU0 +CsD9tY3NoaTSCeYc7Xae5YCXr3viH8vWPap984XZWLJZXM2s2Vm1XdeBTQxWCGVE +R9dnzGGEDsHNOZoy3UpsL+GQ+AORGsEAjOVJQnCjqUh7gtB8cwiPvps= +=UjLO +-----END PGP PUBLIC KEY BLOCK----- +-----BEGIN PGP PUBLIC KEY BLOCK----- +Comment: FF74 72FE 5592 BAD8 D006 B397 D1BC 4FFE 4B46 67B2 + +xjMEZiAxAhYJKwYBBAHaRw8BAQdAS9WhD3A6isn3c7gjiA8RO3sL6TA898vQTu9h +tBiEtybOMwRmIDJvFgkrBgEEAdpHDwEBB0ALgJDXs1IwahnR8RtLdrrPR+axbuGf +QdpHekl5X/CvEcJ+BBgWCgAmFiEE/3Ry/lWSutjQBrOX0bxP/ktGZ7IFAmYgMm8C +GyAFCQPCZwAACgkQ0bxP/ktGZ7KxKAD/df30ImVJc43/hxNxWFHpEnTfUX+SPL3e +SP3K3XASL6UA/3cTiHSU6gs2utd74ab7KRJuiCRELM19UFf1lrkP7X8BzjMEZiAy +bhYJKwYBBAHaRw8BAQdAa2W2iwXRN1LhYYrFE/lDr+LJYso2b4NU1F01ddQBH7fC +wDUEGBYKACYWIQT/dHL+VZK62NAGs5fRvE/+S0ZnsgUCZiAybgIbAgUJA8JnAACB +CRDRvE/+S0ZnsnYgBBkWCgAdFiEEfwxnAPgrlwkgkq/4Tak8C1epixEFAmYgMm4A +CgkQTak8C1epixFE+gD+OEX9f7gZo8sSNLX1LgU+QsmV9HTwd19pk33/FByj1jYB +AMZA6p09rXTEbCklacoT4RwvM95Dw1YU9SWexWrNHjYP/RAA/RGXRiD6ADsZSbAz +gKsr+/QT/HuyueEp4PgHDPJ/eEOJAP4hTZnr/i5U//dYi56hGfkpMf6zx50nbzeV +Xqc8QHiXBM44BGYgMusSCisGAQQBl1UBBQEBB0AdRlUE9qbWOUxKWXM3+tphIbVJ +9N8KpY9aUlAr1l+hBgMBCAfCfgQYFgoAJhYhBP90cv5VkrrY0Aazl9G8T/5LRmey +BQJmIDLrAhsMBQkDwmcAAAoJENG8T/5LRmeyfh4BAP9QhVHGIhOTEZ5L8Fpkt/2t +0IKS0prJ+epFXWVMsA2XAQCiXUj+ki6x/gn/wRXc5diSgrQhwX7RA4iJO0yxJcyq +Cw== +=98lx +-----END PGP PUBLIC KEY BLOCK----- diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 33addc56828f..9abd37ec7aca 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -279,6 +279,7 @@ + @@ -296,6 +297,7 @@ + From e8948ef17dd067af2f2936400ca5e968e6d8f507 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 4 Dec 2025 21:29:13 +0100 Subject: [PATCH 05/20] feat(unifiedpush): Add settings to enable UnifiedPush Signed-off-by: sim --- .../com/owncloud/android/utils/PushUtils.java | 5 + .../com/owncloud/android/utils/PushUtils.java | 55 ++++++++- .../com/owncloud/android/utils/PushUtils.java | 5 + .../client/preferences/AppPreferences.java | 3 + .../preferences/AppPreferencesImpl.java | 11 ++ .../datamodel/PushConfigurationState.java | 2 + .../android/ui/activity/SettingsActivity.java | 55 +++++++++ .../android/utils/UnifiedPushUtils.kt | 108 +++++++++++++++--- app/src/main/res/values/setup.xml | 1 + app/src/main/res/values/strings.xml | 6 + app/src/main/res/xml/preferences.xml | 16 ++- .../com/owncloud/android/utils/PushUtils.java | 5 + .../com/owncloud/android/utils/PushUtils.java | 5 + 13 files changed, 252 insertions(+), 25 deletions(-) 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..b4fd9505411b 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); } } } @@ -340,7 +385,7 @@ 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(); 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/java/com/nextcloud/client/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/client/preferences/AppPreferences.java index b7d6818ea5a1..10f96a327f78 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,9 @@ default void onDarkThemeModeChanged(DarkMode mode) { boolean isShowHiddenFilesEnabled(); void setShowHiddenFilesEnabled(boolean enabled); + + 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..91469fdaf307 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,16 @@ public void setShowHiddenFilesEnabled(boolean enabled) { preferences.edit().putBoolean(PREF__SHOW_HIDDEN_FILES, enabled).apply(); } + @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/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/ui/activity/SettingsActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java index 65e70c062977..e9a52b306326 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.UnifiedPushUtils; 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,55 @@ 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()); + + if (!fUnifiedPushEnabled || !supportsWebPush) { + preferenceScreen.removePreference(preferenceCategoryPush); + } else { + setUnifiedPushPreference(); + } + } + + private void setUnifiedPushPreference() { + boolean unifiedPushEnabled = preferences.isUnifiedPushEnabled(); + ThemeableSwitchPreference prefUnifiedPush = (ThemeableSwitchPreference) findPreference("enable_unifiedpush"); + Preference prefChangeService = findPreference("change_unifiedpush"); + prefUnifiedPush.setChecked(unifiedPushEnabled); + prefUnifiedPush.setOnPreferenceClickListener(preference -> { + 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()) { + UnifiedPushUtils.useDefaultDistributor(this, accountManager, preferences.getPushToken(), service -> { + prefChangeService.setSummary(Objects.requireNonNullElse(service, "")); + return null; + }); + } else { + UnifiedPushUtils.disableUnifiedPush(accountManager, preferences.getPushToken()); + } + return false; + }); + 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 -> { + //TODO + return false; + }); + } + private void setupMoreCategory() { final PreferenceCategory preferenceCategoryMore = (PreferenceCategory) findPreference("more"); viewThemeUtils.files.themePreferenceCategory(preferenceCategoryMore); diff --git a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt index 5248bc83ae8c..76f98c87fb47 100644 --- a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt @@ -7,44 +7,112 @@ package com.owncloud.android.utils -import android.accounts.Account import android.app.Activity import android.content.Context -import android.util.Log import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.preferences.AppPreferences 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.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 java.util.concurrent.Executors object UnifiedPushUtils { private val TAG: String = UnifiedPushUtils::class.java.getSimpleName() + /** + * 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 useDefaultDistributor(activity: Activity, userAccountManager: UserAccountManager) { - Log.d(TAG, "Using default UnifiedPush distrbutor") - UnifiedPush.tryUseCurrentOrDefaultDistributor(activity as Context) { - userAccountManager.accounts.forEach { account -> - Executors.newSingleThreadExecutor().execute { - registerWebPushForAccount(activity, userAccountManager, account) + fun useDefaultDistributor( + activity: Activity, + accountManager: UserAccountManager, + proxyPushToken: String?, + callback: (String?) -> Unit + ) { + Log_OC.d(TAG, "Using default UnifiedPush distrbutor") + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity as Context) { res -> + if (res) { + registerAllAccounts(activity, accountManager, proxyPushToken) + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + callback(null) + } + } + } + + /** + * Disable UnifiedPush and try to register with proxy push again + */ + @JvmStatic + fun disableUnifiedPush( + accountManager: UserAccountManager, + proxyPushToken: String? + ) { + CoroutineScope(Dispatchers.IO).launch { + for (account in accountManager.getAccounts()) { + PushUtils.setRegistrationForAccountEnabled(account, true) + } + PushUtils.pushRegistrationToServer(accountManager, proxyPushToken) + } + } + + private fun registerAllAccounts( + 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 = registerWebPushForAccount(context, accountManager, ocAccount) + if (res) { + PushUtils.setRegistrationForAccountEnabled(account, false) + } } } + jobs.joinAll() + proxyPushToken?.let { + PushUtils.pushRegistrationToServer(accountManager, it) + } } } - private fun supportsWebPush(context: Context, userAccountManager: UserAccountManager, account: Account): Boolean = - userAccountManager.getUser(account.name) + /** + * 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.d(TAG, "Found push capability: $it") } + .also { Log_OC.d(TAG, "Found push capability: $it") } .orElse(false) - private fun registerWebPushForAccount(context: Context, userAccountManager: UserAccountManager, account: Account) { - Log.d(TAG, "Registering web push for ${account.name}") - if (supportsWebPush(context, userAccountManager, account)) { - val ocAccount = OwnCloudAccount(account, context) - val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, context) + /** + * Register web push on the server if supported + * + * @return true if registration succeed + */ + private fun registerWebPushForAccount( + 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 @@ -55,10 +123,12 @@ object UnifiedPushUtils { vapid = vapid ) } else { - Log.w(TAG, "Couldn't find VAPID for ${account.name}") + Log_OC.w(TAG, "Couldn't find VAPID for ${account.name}") } + return vapidRes.isSuccess } else { - Log.d(TAG, "${account.name}'s server doesn't support web push: aborting.") + 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..636fb2ac9f4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1504,4 +1504,10 @@ 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 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" /> - + + + + + + Date: Thu, 4 Dec 2025 21:45:59 +0100 Subject: [PATCH 06/20] feat(unifiedpush): Register web push and proxy push on startup Signed-off-by: sim --- .../com/owncloud/android/utils/PushUtils.java | 9 ++++++++- .../ui/activity/FileDisplayActivity.kt | 4 ++-- .../android/ui/activity/UserInfoActivity.java | 3 ++- .../android/utils/UnifiedPushUtils.kt | 20 +++++++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) 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 b4fd9505411b..f36ac6c01a29 100644 --- a/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java +++ b/app/src/gplay/java/com/owncloud/android/utils/PushUtils.java @@ -381,6 +381,14 @@ 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(); @@ -397,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/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 651f4741639d..bc9f4d9203fb 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.UnifiedPushUtils 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()) } + UnifiedPushUtils.registerCurrentPushConfiguration(this, userAccountManager, preferences) } public override fun onStart() { 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..28c36d140d93 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 @@ -48,6 +48,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.UnifiedPushUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; import org.greenrobot.eventbus.Subscribe; @@ -359,7 +360,7 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { @Subscribe(threadMode = ThreadMode.BACKGROUND) public void onMessageEvent(TokenPushEvent event) { - PushUtils.pushRegistrationToServer(getUserAccountManager(), preferences.getPushToken()); + UnifiedPushUtils.registerCurrentPushConfiguration(this, getUserAccountManager(), preferences); } diff --git a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt index 76f98c87fb47..c5ec1fb83a6d 100644 --- a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt @@ -69,6 +69,26 @@ object UnifiedPushUtils { } } + /** + * Register UnifiedPush, or FCM with the current config + */ + @JvmStatic + fun registerCurrentPushConfiguration(context: Context, accountManager: UserAccountManager, preferences: AppPreferences) { + if (preferences.isUnifiedPushEnabled) { + UnifiedPush.getAckDistributor(context)?.let { + registerAllAccounts(context, accountManager, preferences.pushToken) + } ?: run { + // The user has uninstalled the distributor, fallback to play services with the proxy push if available + preferences.isUnifiedPushEnabled = false + disableUnifiedPush(accountManager, preferences.pushToken) + } + } else { + CoroutineScope(Dispatchers.IO).launch { + PushUtils.pushRegistrationToServer(accountManager, preferences.pushToken) + } + } + } + private fun registerAllAccounts( context: Context, accountManager: UserAccountManager, From b3266d20455e2901cbb97c115674b5f5b010884d Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 4 Dec 2025 21:55:03 +0100 Subject: [PATCH 07/20] feat(unifiedpush): Unregister UnifiedPush/web push when needed Signed-off-by: sim --- .../client/jobs/AccountRemovalWork.kt | 9 ++++++++ .../android/ui/activity/SettingsActivity.java | 2 +- .../android/utils/UnifiedPushUtils.kt | 21 ++++++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt index ebcb5f7dd4d3..6592b034eb4f 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt @@ -42,6 +42,7 @@ import com.owncloud.android.ui.activity.ManageAccountsActivity import com.owncloud.android.ui.events.AccountRemovedEvent import com.owncloud.android.utils.EncryptionUtils import com.owncloud.android.utils.PushUtils +import com.owncloud.android.utils.UnifiedPushUtils import org.greenrobot.eventbus.EventBus import java.util.Optional @@ -94,6 +95,7 @@ class AccountRemovalWork( ) // unregister push notifications unregisterPushNotifications(context, user, arbitraryDataProvider) + unregisterWebPushNotifications(context, user) // remove pending account removal arbitraryDataProvider.deleteKeyForAccount(user.accountName, ManageAccountsActivity.PENDING_FOR_REMOVAL) @@ -170,6 +172,13 @@ class AccountRemovalWork( } } + private fun unregisterWebPushNotifications( + context: Context, + user: User + ) { + UnifiedPushUtils.unregisterWebPushForAccount(context, userAccountManager, user.toOwnCloudAccount()) + } + private fun removeSyncedFolders(context: Context, user: User, clock: Clock) { val syncedFolders = syncedFolderProvider.syncedFolders val syncedFolderIds: MutableList = ArrayList() 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 e9a52b306326..fb019f156018 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 @@ -391,7 +391,7 @@ private void setUnifiedPushPreference() { return null; }); } else { - UnifiedPushUtils.disableUnifiedPush(accountManager, preferences.getPushToken()); + UnifiedPushUtils.disableUnifiedPush(this, accountManager, preferences.getPushToken()); } return false; }); diff --git a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt index c5ec1fb83a6d..d9024a0de9e8 100644 --- a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt @@ -15,6 +15,7 @@ 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 @@ -58,17 +59,35 @@ object UnifiedPushUtils { */ @JvmStatic fun disableUnifiedPush( + context: Context, accountManager: UserAccountManager, proxyPushToken: String? ) { CoroutineScope(Dispatchers.IO).launch { for (account in accountManager.getAccounts()) { PushUtils.setRegistrationForAccountEnabled(account, true) + unregisterWebPushForAccount(context, accountManager, OwnCloudAccount(account, context)) } PushUtils.pushRegistrationToServer(accountManager, proxyPushToken) } } + @JvmStatic + fun unregisterWebPushForAccount( + 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, or FCM with the current config */ @@ -80,7 +99,7 @@ object UnifiedPushUtils { } ?: run { // The user has uninstalled the distributor, fallback to play services with the proxy push if available preferences.isUnifiedPushEnabled = false - disableUnifiedPush(accountManager, preferences.pushToken) + disableUnifiedPush(context, accountManager, preferences.pushToken) } } else { CoroutineScope(Dispatchers.IO).launch { From 42b1426e991f33ba679f45f2353dc78a4be4ed53 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 5 Dec 2025 09:09:53 +0100 Subject: [PATCH 08/20] feat(unifiedpush): Do not register for talk notif if installed Signed-off-by: sim --- .../com/owncloud/android/services/UnifiedPushService.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt index 49a66d066393..12869f5a5fd7 100644 --- a/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt +++ b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt @@ -10,6 +10,7 @@ package com.owncloud.android.services import android.util.Log import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.utils.LinkHelper.APP_NEXTCLOUD_TALK import com.owncloud.android.lib.common.OwnCloudAccount import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.resources.notifications.ActivateWebPushRegistrationOperation @@ -36,6 +37,11 @@ class UnifiedPushService: PushService() { AndroidInjection.inject(this) } + private fun apptypes(): List = packageManager + .getLaunchIntentForPackage(APP_NEXTCLOUD_TALK)?.let { + listOf("all", "-talk") + } ?: listOf("all") + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { Log.d(TAG, "Received new endpoint for $instance") // No reason to fail with the default key manager @@ -47,7 +53,7 @@ class UnifiedPushService: PushService() { endpoint = endpoint.url, auth = key.auth, uaPublicKey = key.pubKey, - apptypes = listOf("all") // TODO: remove talk if installed + apptypes = apptypes() ).execute(mClient) } } From 45b746fbd9f5a5fc9635ea66de975267a1064f39 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 5 Dec 2025 15:32:24 +0100 Subject: [PATCH 09/20] feat(unifiedpush): Allow selecting another distrib Signed-off-by: sim --- .../android/ui/activity/SettingsActivity.java | 7 ++++- .../android/utils/UnifiedPushUtils.kt | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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 fb019f156018..10dd7c6972f7 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 @@ -403,7 +403,12 @@ private void setUnifiedPushPreference() { } prefChangeService.setEnabled(unifiedPushEnabled); prefChangeService.setOnPreferenceClickListener(preference -> { - //TODO + UnifiedPushUtils.pickDistributor(this, accountManager, preferences.getPushToken(), service -> { + if (service != null) { + prefChangeService.setSummary(service); + } + return null; + }); return false; }); } diff --git a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt index d9024a0de9e8..1784f5d483f4 100644 --- a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt @@ -54,6 +54,34 @@ object UnifiedPushUtils { } } + /** + * 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 pickDistributor( + 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) { + registerAllAccounts(activity, accountManager, proxyPushToken) + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + callback(null) + } + } + } + /** * Disable UnifiedPush and try to register with proxy push again */ From a5a7b19a7417b515d6ed2d2780aba320fb39d3b9 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 5 Dec 2025 15:44:57 +0100 Subject: [PATCH 10/20] feat(unifiedpush): Show UnifiedPush settings only if a service is available Signed-off-by: sim --- .../android/ui/activity/SettingsActivity.java | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) 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 10dd7c6972f7..3e0cf63e2869 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 @@ -367,27 +367,32 @@ private void setupPushCategory(PreferenceScreen preferenceScreen) { boolean supportsWebPush = accountManager.getAllUsers() .stream() .anyMatch(u -> CapabilityUtils.getCapability(u, this).getSupportsWebPush().isTrue()); - - if (!fUnifiedPushEnabled || !supportsWebPush) { + int nPushServices = UnifiedPush.getDistributors(this).size(); + if (!fUnifiedPushEnabled || !supportsWebPush || nPushServices == 0) { preferenceScreen.removePreference(preferenceCategoryPush); } else { - setUnifiedPushPreference(); + setUnifiedPushPreference(nPushServices > 1); } } - private void setUnifiedPushPreference() { + 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 -> { - 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)); + 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()) { UnifiedPushUtils.useDefaultDistributor(this, accountManager, preferences.getPushToken(), service -> { - prefChangeService.setSummary(Objects.requireNonNullElse(service, "")); + if (prefChangeService != null) { + prefChangeService.setSummary(Objects.requireNonNullElse(service, "")); + } return null; }); } else { @@ -395,22 +400,27 @@ private void setUnifiedPushPreference() { } return false; }); - if (unifiedPushEnabled) { - String service = UnifiedPush.getAckDistributor(this); - prefChangeService.setSummary(Objects.requireNonNullElse(service, "")); + + 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 -> { + UnifiedPushUtils.pickDistributor(this, accountManager, preferences.getPushToken(), service -> { + if (service != null) { + prefChangeService.setSummary(service); + } + return null; + }); + return false; + }); } else { - prefChangeService.setSummary(getResources().getString(R.string.prefs_disabled_push_system_summary)); + prefChangeService.getParent().removePreference(prefChangeService); } - prefChangeService.setEnabled(unifiedPushEnabled); - prefChangeService.setOnPreferenceClickListener(preference -> { - UnifiedPushUtils.pickDistributor(this, accountManager, preferences.getPushToken(), service -> { - if (service != null) { - prefChangeService.setSummary(service); - } - return null; - }); - return false; - }); } private void setupMoreCategory() { From d75b3b4ae077b410f406e1634f12e7330fa61a76 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 13 Feb 2026 09:23:27 +0100 Subject: [PATCH 11/20] feat(webpush): Fix appTypes name Signed-off-by: sim --- .../java/com/owncloud/android/services/UnifiedPushService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt index 12869f5a5fd7..587501bb3c7b 100644 --- a/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt +++ b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt @@ -53,7 +53,7 @@ class UnifiedPushService: PushService() { endpoint = endpoint.url, auth = key.auth, uaPublicKey = key.pubKey, - apptypes = apptypes() + appTypes = apptypes() ).execute(mClient) } } From 9266be05b26793d06771df3dcc54de27cd8c5189 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 13 Feb 2026 14:10:55 +0100 Subject: [PATCH 12/20] feat(unifiedpush): Clarify object and function names To catch easily when we do UnifiedPush or Common push tasks Signed-off-by: sim --- .../client/jobs/AccountRemovalWork.kt | 4 +- .../ui/activity/FileDisplayActivity.kt | 4 +- .../android/ui/activity/SettingsActivity.java | 8 +- .../android/ui/activity/UserInfoActivity.java | 5 +- ...UnifiedPushUtils.kt => CommonPushUtils.kt} | 95 +++++++++++-------- 5 files changed, 65 insertions(+), 51 deletions(-) rename app/src/main/java/com/owncloud/android/utils/{UnifiedPushUtils.kt => CommonPushUtils.kt} (80%) diff --git a/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt index 6592b034eb4f..3e35b36aa7cf 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/AccountRemovalWork.kt @@ -42,7 +42,7 @@ import com.owncloud.android.ui.activity.ManageAccountsActivity import com.owncloud.android.ui.events.AccountRemovedEvent import com.owncloud.android.utils.EncryptionUtils import com.owncloud.android.utils.PushUtils -import com.owncloud.android.utils.UnifiedPushUtils +import com.owncloud.android.utils.CommonPushUtils import org.greenrobot.eventbus.EventBus import java.util.Optional @@ -176,7 +176,7 @@ class AccountRemovalWork( context: Context, user: User ) { - UnifiedPushUtils.unregisterWebPushForAccount(context, userAccountManager, user.toOwnCloudAccount()) + CommonPushUtils.unregisterUnifiedPushForAccount(context, userAccountManager, user.toOwnCloudAccount()) } private fun removeSyncedFolders(context: Context, user: User, clock: Clock) { 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 bc9f4d9203fb..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,7 +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.UnifiedPushUtils +import com.owncloud.android.utils.CommonPushUtils import com.owncloud.android.utils.theme.CapabilityUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -2771,7 +2771,7 @@ class FileDisplayActivity : if (!preferences.isKeysReInitEnabled()) { PushUtils.reinitKeys(userAccountManager) } - UnifiedPushUtils.registerCurrentPushConfiguration(this, userAccountManager, preferences) + 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 3e0cf63e2869..59e1fd529dab 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,7 +77,7 @@ import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.MimeTypeUtil; import com.owncloud.android.utils.PermissionUtil; -import com.owncloud.android.utils.UnifiedPushUtils; +import com.owncloud.android.utils.CommonPushUtils; import com.owncloud.android.utils.theme.CapabilityUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; @@ -389,14 +389,14 @@ private void setUnifiedPushPreference(boolean canChangeService) { } preferences.setUnifiedPushEnabled(prefUnifiedPush.isChecked()); if (prefUnifiedPush.isChecked()) { - UnifiedPushUtils.useDefaultDistributor(this, accountManager, preferences.getPushToken(), service -> { + CommonPushUtils.useDefaultUnifiedPushDistributor(this, accountManager, preferences.getPushToken(), service -> { if (prefChangeService != null) { prefChangeService.setSummary(Objects.requireNonNullElse(service, "")); } return null; }); } else { - UnifiedPushUtils.disableUnifiedPush(this, accountManager, preferences.getPushToken()); + CommonPushUtils.disableUnifiedPush(this, accountManager, preferences.getPushToken()); } return false; }); @@ -410,7 +410,7 @@ private void setUnifiedPushPreference(boolean canChangeService) { } prefChangeService.setEnabled(unifiedPushEnabled); prefChangeService.setOnPreferenceClickListener(preference -> { - UnifiedPushUtils.pickDistributor(this, accountManager, preferences.getPushToken(), service -> { + CommonPushUtils.pickUnifiedPushDistributor(this, accountManager, preferences.getPushToken(), service -> { if (service != null) { prefChangeService.setSummary(service); } 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 28c36d140d93..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,8 +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.UnifiedPushUtils; +import com.owncloud.android.utils.CommonPushUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; import org.greenrobot.eventbus.Subscribe; @@ -360,7 +359,7 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { @Subscribe(threadMode = ThreadMode.BACKGROUND) public void onMessageEvent(TokenPushEvent event) { - UnifiedPushUtils.registerCurrentPushConfiguration(this, getUserAccountManager(), preferences); + CommonPushUtils.registerCurrentPushConfiguration(this, getUserAccountManager(), preferences); } diff --git a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt similarity index 80% rename from app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt rename to app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt index 1784f5d483f4..6d6a7bdb04fe 100644 --- a/app/src/main/java/com/owncloud/android/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt @@ -23,8 +23,43 @@ import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import org.unifiedpush.android.connector.UnifiedPush -object UnifiedPushUtils { - private val TAG: String = UnifiedPushUtils::class.java.getSimpleName() +/** + * 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 + */ + @JvmStatic + fun registerCurrentPushConfiguration(context: Context, accountManager: UserAccountManager, preferences: AppPreferences) { + if (preferences.isUnifiedPushEnabled) { + UnifiedPush.getAckDistributor(context)?.let { + registerUnifiedPushForAllAccounts(context, accountManager, preferences.pushToken) + } ?: run { + // The user has uninstalled the distributor, fallback to play services with the proxy push if available + preferences.isUnifiedPushEnabled = false + disableUnifiedPush(context, accountManager, preferences.pushToken) + } + } 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 @@ -37,16 +72,16 @@ object UnifiedPushUtils { * @param callback: run with the push service name if available */ @JvmStatic - fun useDefaultDistributor( + fun useDefaultUnifiedPushDistributor( activity: Activity, accountManager: UserAccountManager, proxyPushToken: String?, callback: (String?) -> Unit ) { - Log_OC.d(TAG, "Using default UnifiedPush distrbutor") + Log_OC.d(TAG, "Using default UnifiedPush distributor") UnifiedPush.tryUseCurrentOrDefaultDistributor(activity as Context) { res -> if (res) { - registerAllAccounts(activity, accountManager, proxyPushToken) + registerUnifiedPushForAllAccounts(activity, accountManager, proxyPushToken) callback(UnifiedPush.getSavedDistributor(activity)) } else { callback(null) @@ -65,7 +100,7 @@ object UnifiedPushUtils { * @param callback: run with the push service name if available */ @JvmStatic - fun pickDistributor( + fun pickUnifiedPushDistributor( activity: Activity, accountManager: UserAccountManager, proxyPushToken: String?, @@ -74,7 +109,7 @@ object UnifiedPushUtils { Log_OC.d(TAG, "Picking another UnifiedPush distributor") UnifiedPush.tryPickDistributor(activity as Context) { res -> if (res) { - registerAllAccounts(activity, accountManager, proxyPushToken) + registerUnifiedPushForAllAccounts(activity, accountManager, proxyPushToken) callback(UnifiedPush.getSavedDistributor(activity)) } else { callback(null) @@ -94,14 +129,14 @@ object UnifiedPushUtils { CoroutineScope(Dispatchers.IO).launch { for (account in accountManager.getAccounts()) { PushUtils.setRegistrationForAccountEnabled(account, true) - unregisterWebPushForAccount(context, accountManager, OwnCloudAccount(account, context)) + unregisterUnifiedPushForAccount(context, accountManager, OwnCloudAccount(account, context)) } PushUtils.pushRegistrationToServer(accountManager, proxyPushToken) } } @JvmStatic - fun unregisterWebPushForAccount( + fun unregisterUnifiedPushForAccount( context: Context, accountManager: UserAccountManager, account: OwnCloudAccount @@ -117,26 +152,13 @@ object UnifiedPushUtils { } /** - * Register UnifiedPush, or FCM with the current config + * 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 */ - @JvmStatic - fun registerCurrentPushConfiguration(context: Context, accountManager: UserAccountManager, preferences: AppPreferences) { - if (preferences.isUnifiedPushEnabled) { - UnifiedPush.getAckDistributor(context)?.let { - registerAllAccounts(context, accountManager, preferences.pushToken) - } ?: run { - // The user has uninstalled the distributor, fallback to play services with the proxy push if available - preferences.isUnifiedPushEnabled = false - disableUnifiedPush(context, accountManager, preferences.pushToken) - } - } else { - CoroutineScope(Dispatchers.IO).launch { - PushUtils.pushRegistrationToServer(accountManager, preferences.pushToken) - } - } - } - - private fun registerAllAccounts( + private fun registerUnifiedPushForAllAccounts( context: Context, accountManager: UserAccountManager, proxyPushToken: String? @@ -145,7 +167,7 @@ object UnifiedPushUtils { val jobs = accountManager.accounts.map { account -> CoroutineScope(Dispatchers.IO).launch { val ocAccount = OwnCloudAccount(account, context) - val res = registerWebPushForAccount(context, accountManager, ocAccount) + val res = registerUnifiedPushForAccount(context, accountManager, ocAccount) if (res) { PushUtils.setRegistrationForAccountEnabled(account, false) } @@ -159,20 +181,13 @@ object UnifiedPushUtils { } /** - * 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) - - /** - * Register web push on the server if supported + * 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 registerWebPushForAccount( + private fun registerUnifiedPushForAccount( context: Context, accountManager: UserAccountManager, account: OwnCloudAccount From db60c6be349eff0fedea2a8406c64b5e1d13eb98 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 17:59:18 +0100 Subject: [PATCH 13/20] feat(unifiedpush): Fix preference when dismissed Signed-off-by: sim --- .../com/owncloud/android/ui/activity/SettingsActivity.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 59e1fd529dab..e96211c100f4 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 @@ -390,8 +390,11 @@ private void setUnifiedPushPreference(boolean canChangeService) { preferences.setUnifiedPushEnabled(prefUnifiedPush.isChecked()); if (prefUnifiedPush.isChecked()) { CommonPushUtils.useDefaultUnifiedPushDistributor(this, accountManager, preferences.getPushToken(), service -> { - if (prefChangeService != null) { + if (service != null) { prefChangeService.setSummary(Objects.requireNonNullElse(service, "")); + } else { + prefUnifiedPush.setChecked(false); + prefChangeService.setEnabled(false); } return null; }); From 080c8c36872abaf80029835b362a13f812f71875 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 18:03:02 +0100 Subject: [PATCH 14/20] feat(unifiedpush): Show introduction dialog if the user needs to pick a distributor Signed-off-by: sim --- .../android/ui/activity/SettingsActivity.java | 2 +- .../owncloud/android/utils/CommonPushUtils.kt | 92 +++++++++++++++---- app/src/main/res/values/strings.xml | 2 + 3 files changed, 78 insertions(+), 18 deletions(-) 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 e96211c100f4..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 @@ -389,7 +389,7 @@ private void setUnifiedPushPreference(boolean canChangeService) { } preferences.setUnifiedPushEnabled(prefUnifiedPush.isChecked()); if (prefUnifiedPush.isChecked()) { - CommonPushUtils.useDefaultUnifiedPushDistributor(this, accountManager, preferences.getPushToken(), service -> { + CommonPushUtils.tryUseUnifiedPush(this, accountManager, preferences, service -> { if (service != null) { prefChangeService.setSummary(Objects.requireNonNullElse(service, "")); } else { diff --git a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt index 6d6a7bdb04fe..c229d4810837 100644 --- a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt @@ -9,8 +9,12 @@ 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 @@ -22,6 +26,7 @@ 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 @@ -34,17 +39,13 @@ object CommonPushUtils { * * 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(context: Context, accountManager: UserAccountManager, preferences: AppPreferences) { - if (preferences.isUnifiedPushEnabled) { - UnifiedPush.getAckDistributor(context)?.let { - registerUnifiedPushForAllAccounts(context, accountManager, preferences.pushToken) - } ?: run { - // The user has uninstalled the distributor, fallback to play services with the proxy push if available - preferences.isUnifiedPushEnabled = false - disableUnifiedPush(context, accountManager, preferences.pushToken) - } + fun registerCurrentPushConfiguration(activity: Activity, accountManager: UserAccountManager, preferences: AppPreferences) { + if (preferences.isUnifiedPushEnabled){ + tryUseUnifiedPush(activity, accountManager, preferences) {} } else { CoroutineScope(Dispatchers.IO).launch { PushUtils.pushRegistrationToServer(accountManager, preferences.pushToken) @@ -72,23 +73,80 @@ object CommonPushUtils { * @param callback: run with the push service name if available */ @JvmStatic - fun useDefaultUnifiedPushDistributor( + fun tryUseUnifiedPush( activity: Activity, accountManager: UserAccountManager, - proxyPushToken: String?, + preferences: AppPreferences, callback: (String?) -> Unit ) { - Log_OC.d(TAG, "Using default UnifiedPush distributor") - UnifiedPush.tryUseCurrentOrDefaultDistributor(activity as Context) { res -> - if (res) { - registerUnifiedPushForAllAccounts(activity, accountManager, proxyPushToken) - callback(UnifiedPush.getSavedDistributor(activity)) - } else { + UnifiedPush.getAckDistributor(activity)?.let { + registerUnifiedPushForAllAccounts(activity, accountManager, preferences.pushToken) + callback(it) + return + } + when (val res = UnifiedPush.resolveDefaultDistributor(activity)) { + is ResolvedDistributor.Found -> { + preferences.isUnifiedPushEnabled = true + UnifiedPush.saveDistributor(activity, res.packageName) + registerUnifiedPushForAllAccounts(activity, accountManager, preferences.pushToken) + callback(res.packageName) + } + ResolvedDistributor.NoneAvailable -> { + // Do not change preference + disableUnifiedPush(activity, accountManager, preferences.pushToken) callback(null) } + ResolvedDistributor.ToSelect -> { + 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 { + 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 * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 636fb2ac9f4d..aee102c13a90 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1510,4 +1510,6 @@ Enable UnifiedPush Receive push notifications with an external UnifiedPush service Enable UnifiedPush first + UnifiedPush + You are about to select your default push service From 7254db8d61d7ddd499aba8d54c9bf38bc1c22268 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 18:03:57 +0100 Subject: [PATCH 15/20] feat(unifiedpush): Enable UnifiedPush by default for generic flavor Signed-off-by: sim --- app/build.gradle.kts | 5 +++++ .../com/nextcloud/client/preferences/AppPreferences.java | 2 ++ .../com/nextcloud/client/preferences/AppPreferencesImpl.java | 5 +++++ .../main/java/com/owncloud/android/utils/CommonPushUtils.kt | 5 ++++- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dc72e664d939..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") } } } 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 10f96a327f78..27b47f6ef198 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,8 @@ default void onDarkThemeModeChanged(DarkMode mode) { boolean isShowHiddenFilesEnabled(); void setShowHiddenFilesEnabled(boolean enabled); + boolean isPushInitialized(); + boolean isUnifiedPushEnabled(); void setUnifiedPushEnabled(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 91469fdaf307..1d5530b03963 100644 --- a/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java +++ b/app/src/main/java/com/nextcloud/client/preferences/AppPreferencesImpl.java @@ -235,6 +235,11 @@ 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); diff --git a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt index c229d4810837..bbee04b3f11b 100644 --- a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt @@ -44,7 +44,10 @@ object CommonPushUtils { */ @JvmStatic fun registerCurrentPushConfiguration(activity: Activity, accountManager: UserAccountManager, preferences: AppPreferences) { - if (preferences.isUnifiedPushEnabled){ + if ( + (!preferences.isPushInitialized && BuildConfig.DEFAULT_PUSH_UNIFIEDPUSH) + || preferences.isUnifiedPushEnabled + ){ tryUseUnifiedPush(activity, accountManager, preferences) {} } else { CoroutineScope(Dispatchers.IO).launch { From 984ac4b2223857440c8652f7412a912d78560fa0 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 20:30:42 +0100 Subject: [PATCH 16/20] feat(unifiedpush): Use worker to process UnifiedPush events Signed-off-by: sim --- .../client/jobs/BackgroundJobFactory.kt | 7 ++ .../client/jobs/BackgroundJobManager.kt | 3 + .../client/jobs/BackgroundJobManagerImpl.kt | 44 +++++++ .../nextcloud/client/jobs/UnifiedPushWork.kt | 108 ++++++++++++++++++ .../android/services/UnifiedPushService.kt | 37 +----- 5 files changed, 165 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt 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..6ef037d6a125 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,12 @@ class BackgroundJobFactory @Inject constructor( viewThemeUtils.get() ) + private fun createUnifiedPushWork(context: Context, params: WorkerParameters): UnifiedPushWork = UnifiedPushWork( + context, + params, + accountManager, + ) + 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 1cd5efce8b5b..27dbe38b3803 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -135,6 +135,9 @@ interface BackgroundJobManager { 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 startAccountRemovalJob(accountName: String, remoteWipe: Boolean) fun startFilesUploadJob(user: User, uploadIds: LongArray, showSameFileAlreadyExistsNotification: Boolean) 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 47ca2d59b0b9..f2690fcb3138 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" @@ -607,6 +608,49 @@ internal class BackgroundJobManagerImpl( 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) + } + 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/UnifiedPushWork.kt b/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt new file mode 100644 index 000000000000..a521e9a2a807 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt @@ -0,0 +1,108 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.jobs + +import android.content.Context +import android.util.Log +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.utils.LinkHelper.APP_NEXTCLOUD_TALK +import com.owncloud.android.lib.common.OwnCloudAccount +import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.owncloud.android.lib.resources.notifications.ActivateWebPushRegistrationOperation +import com.owncloud.android.lib.resources.notifications.RegisterAccountDeviceForWebPushOperation +import com.owncloud.android.lib.resources.notifications.UnregisterAccountDeviceForWebPushOperation + +class UnifiedPushWork( + private val context: Context, + params: WorkerParameters, + private val accountManager: UserAccountManager +) : Worker(context, params) { + override fun doWork(): Result { + when (val action = inputData.getString(ACTION)) { + ACTION_ACTIVATE -> activate() + ACTION_REGISTER -> register() + ACTION_UNREGISTER -> unregister() + 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) + } + + companion object { + private const val TAG = "UnifiedPushWork" + const val ACTION = "action" + const val ACTION_ACTIVATE = "action.activate" + const val ACTION_REGISTER = "action.register" + const val ACTION_UNREGISTER = "action.unregister" + 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/owncloud/android/services/UnifiedPushService.kt b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt index 587501bb3c7b..8efcfa087c80 100644 --- a/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt +++ b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt @@ -10,12 +10,6 @@ package com.owncloud.android.services import android.util.Log import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.jobs.BackgroundJobManager -import com.nextcloud.utils.LinkHelper.APP_NEXTCLOUD_TALK -import com.owncloud.android.lib.common.OwnCloudAccount -import com.owncloud.android.lib.common.OwnCloudClientManagerFactory -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 dagger.android.AndroidInjection import org.json.JSONException import org.json.JSONObject @@ -23,7 +17,6 @@ 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 java.util.concurrent.Executors import javax.inject.Inject class UnifiedPushService: PushService() { @@ -37,25 +30,11 @@ class UnifiedPushService: PushService() { AndroidInjection.inject(this) } - private fun apptypes(): List = packageManager - .getLaunchIntentForPackage(APP_NEXTCLOUD_TALK)?.let { - listOf("all", "-talk") - } ?: listOf("all") - 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 - Executors.newSingleThreadExecutor().execute { - val ocAccount = OwnCloudAccount(accountManager.getAccountByName(instance), this) - val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, this) - RegisterAccountDeviceForWebPushOperation( - endpoint = endpoint.url, - auth = key.auth, - uaPublicKey = key.pubKey, - appTypes = apptypes() - ).execute(mClient) - } + backgroundJobManager.registerWebPush(instance, endpoint.url,key.pubKey, key.auth) } override fun onMessage(message: PushMessage, instance: String) { @@ -63,12 +42,7 @@ class UnifiedPushService: PushService() { try { val mObj = JSONObject(message.content.toString(Charsets.UTF_8)) val token = mObj.getString("activationToken") - Executors.newSingleThreadExecutor().execute { - val ocAccount = OwnCloudAccount(accountManager.getAccountByName(instance), this) - val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, this) - ActivateWebPushRegistrationOperation(token) - .execute(mClient) - } + backgroundJobManager.activateWebPush(instance, token) } catch (_: JSONException) { // Messages are encrypted following RFC8291, and UnifiedPush lib handle the decryption itself: // message.content is the cleartext @@ -82,12 +56,7 @@ class UnifiedPushService: PushService() { override fun onUnregistered(instance: String) { Log.d(TAG, "Unregistered: $instance") - Executors.newSingleThreadExecutor().execute { - val ocAccount = OwnCloudAccount(accountManager.getAccountByName(instance), this) - val mClient = OwnCloudClientManagerFactory.getDefaultSingleton().getNextcloudClientFor(ocAccount, this) - UnregisterAccountDeviceForWebPushOperation() - .execute(mClient) - } + backgroundJobManager.unregisterWebPush(instance) } companion object { From 5e5dc6603ce0557071cc0b4a92776c0bdc6ee645 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 22:07:16 +0100 Subject: [PATCH 17/20] feat(unifiedpush): Fix dialog by running in the UI thread Signed-off-by: sim --- .../owncloud/android/utils/CommonPushUtils.kt | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt index bbee04b3f11b..994c48efb871 100644 --- a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt @@ -83,40 +83,47 @@ object CommonPushUtils { 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 -> { - 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) + 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) } - } else { - preferences.isUnifiedPushEnabled = false - disableUnifiedPush(activity, accountManager, preferences.pushToken) - callback(null) } } } From 545b881e44fe63fda7c417f1f0066ab5ec516177 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 22:10:54 +0100 Subject: [PATCH 18/20] feat(unifiedpush): Show notification to ask user to open the app on unregistration Signed-off-by: sim --- .../client/jobs/BackgroundJobFactory.kt | 2 + .../client/jobs/BackgroundJobManager.kt | 1 + .../client/jobs/BackgroundJobManagerImpl.kt | 23 +++++ .../nextcloud/client/jobs/UnifiedPushWork.kt | 86 ++++++++++++++++++- .../android/services/UnifiedPushService.kt | 1 + .../owncloud/android/utils/CommonPushUtils.kt | 2 +- app/src/main/res/values/strings.xml | 2 + 7 files changed, 115 insertions(+), 2 deletions(-) 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 6ef037d6a125..13b79210cb8f 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -220,6 +220,8 @@ class BackgroundJobFactory @Inject constructor( context, params, accountManager, + preferences, + viewThemeUtils.get() ) private fun createAccountRemovalWork(context: Context, params: WorkerParameters): AccountRemovalWork = 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 27dbe38b3803..90ebeab3ef09 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManager.kt @@ -138,6 +138,7 @@ interface BackgroundJobManager { 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) 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 f2690fcb3138..6ec474e6122e 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt @@ -107,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 @@ -651,6 +652,28 @@ internal class BackgroundJobManagerImpl( 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/UnifiedPushWork.kt b/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt index a521e9a2a807..223d5d4c807f 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt @@ -7,28 +7,49 @@ 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.utils.LinkHelper.APP_NEXTCLOUD_TALK +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 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() @@ -93,12 +114,75 @@ class UnifiedPushWork( .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 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" diff --git a/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt index 8efcfa087c80..db18b12cf85b 100644 --- a/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt +++ b/app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt @@ -57,6 +57,7 @@ class UnifiedPushService: PushService() { override fun onUnregistered(instance: String) { Log.d(TAG, "Unregistered: $instance") backgroundJobManager.unregisterWebPush(instance) + backgroundJobManager.mayResetUnifiedPush() } companion object { diff --git a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt index 994c48efb871..9b530decf16f 100644 --- a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt @@ -226,7 +226,7 @@ object CommonPushUtils { * * 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 */ - private fun registerUnifiedPushForAllAccounts( + fun registerUnifiedPushForAllAccounts( context: Context, accountManager: UserAccountManager, proxyPushToken: String? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aee102c13a90..fc8ac788f266 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1512,4 +1512,6 @@ 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 From 84bf56d9a797e8fcdbd429c93a545313922b5948 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 09:11:03 +0100 Subject: [PATCH 19/20] feat(unifiedpush): Fix missing nextcloud talk pkg name Signed-off-by: sim --- app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt b/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt index 223d5d4c807f..52867bc4375e 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt @@ -21,7 +21,6 @@ import androidx.work.Worker import androidx.work.WorkerParameters import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.preferences.AppPreferences -import com.nextcloud.utils.LinkHelper.APP_NEXTCLOUD_TALK import com.owncloud.android.R import com.owncloud.android.lib.common.OwnCloudAccount import com.owncloud.android.lib.common.OwnCloudClientManagerFactory @@ -178,6 +177,7 @@ class UnifiedPushWork( 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" From f2a107afeb4bbc208da763dd72c6562db1388aa1 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 5 Dec 2025 17:07:22 +0100 Subject: [PATCH 20/20] feat(fcm-generic): Add push notification with FCM on generic build without proprio lib Signed-off-by: sim --- README.md | 2 +- app/build.gradle.kts | 1 + .../android/ui/activity/SettingsActivity.java | 4 +- .../owncloud/android/utils/CommonPushUtils.kt | 41 +++++++++++++++---- gradle/libs.versions.toml | 2 + gradle/verification-metadata.xml | 1 + 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1a23a124eb06..a1500aae9335 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ or ### Push notifications do not work on F-Droid editions -Push Notifications are not currently supported in the F-Droid builds due to dependencies on Google Play services. +Push Notifications are not currently supported in the F-Droid builds with Nextcloud up to version 32, due to dependencies on Google Play services. ## Remarks :scroll: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1d336cee4227..93cabd25e549 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -507,6 +507,7 @@ dependencies { // region Push implementation(libs.unifiedpush.connector) + "genericImplementation"(libs.unifiedpush.embedded.fcm.distributor) // endregion // region common 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 2534b5e017db..072ac4a06c7d 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 @@ -367,7 +367,7 @@ private void setupPushCategory(PreferenceScreen preferenceScreen) { boolean supportsWebPush = accountManager.getAllUsers() .stream() .anyMatch(u -> CapabilityUtils.getCapability(u, this).getSupportsWebPush().isTrue()); - int nPushServices = UnifiedPush.getDistributors(this).size(); + int nPushServices = CommonPushUtils.countExternalServices(this); if (!fUnifiedPushEnabled || !supportsWebPush || nPushServices == 0) { preferenceScreen.removePreference(preferenceCategoryPush); } else { @@ -399,7 +399,7 @@ private void setUnifiedPushPreference(boolean canChangeService) { return null; }); } else { - CommonPushUtils.disableUnifiedPush(this, accountManager, preferences.getPushToken()); + CommonPushUtils.disableExternalUnifiedPush(this, accountManager, preferences.getPushToken()); } return false; }); diff --git a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt index 9b530decf16f..73668fbe3b1a 100644 --- a/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/CommonPushUtils.kt @@ -51,7 +51,14 @@ object CommonPushUtils { tryUseUnifiedPush(activity, accountManager, preferences) {} } else { CoroutineScope(Dispatchers.IO).launch { - PushUtils.pushRegistrationToServer(accountManager, preferences.pushToken) + // The generic flavor has an embedded distrbutor to work with the + // Play Services, other flavors work with Proxy-Push + if (activity.packageName in UnifiedPush.getDistributors(activity)) { + UnifiedPush.saveDistributor(activity, activity.packageName) + registerUnifiedPushForAllAccounts(activity, accountManager, null) + } else { + PushUtils.pushRegistrationToServer(accountManager, preferences.pushToken) + } } } } @@ -65,6 +72,14 @@ object CommonPushUtils { .also { Log_OC.d(TAG, "Found push capability: $it") } .orElse(false) + /** + * Count external push services + */ + @JvmStatic + fun countExternalServices(context: Context): Int = UnifiedPush.getDistributors(context) + .filter { s -> s != context.packageName } + .size + /** * Use default distributor, register all accounts that support webpush * @@ -99,7 +114,7 @@ object CommonPushUtils { ResolvedDistributor.NoneAvailable -> { Log_OC.d(TAG, "No default distributor") // Do not change preference - disableUnifiedPush(activity, accountManager, preferences.pushToken) + disableExternalUnifiedPush(activity, accountManager, preferences.pushToken) callback(null) } ResolvedDistributor.ToSelect -> { @@ -114,14 +129,14 @@ object CommonPushUtils { callback(UnifiedPush.getSavedDistributor(activity)) } else { preferences.isUnifiedPushEnabled = false - disableUnifiedPush(activity, accountManager, preferences.pushToken) + disableExternalUnifiedPush(activity, accountManager, preferences.pushToken) callback(null) } } } else { Log_OC.d(TAG, "Default distributor dismissed") preferences.isUnifiedPushEnabled = false - disableUnifiedPush(activity, accountManager, preferences.pushToken) + disableExternalUnifiedPush(activity, accountManager, preferences.pushToken) callback(null) } } @@ -189,17 +204,27 @@ object CommonPushUtils { * Disable UnifiedPush and try to register with proxy push again */ @JvmStatic - fun disableUnifiedPush( + fun disableExternalUnifiedPush( context: Context, accountManager: UserAccountManager, proxyPushToken: String? ) { CoroutineScope(Dispatchers.IO).launch { + val hasEmbeddedDistrib = context.packageName in UnifiedPush.getDistributors(context) + for (account in accountManager.getAccounts()) { PushUtils.setRegistrationForAccountEnabled(account, true) - unregisterUnifiedPushForAccount(context, accountManager, OwnCloudAccount(account, context)) + if (!hasEmbeddedDistrib) unregisterUnifiedPushForAccount(context, accountManager, OwnCloudAccount(account, context)) + } + // If the app has an embedded distributor, then we try to use it as a fallback + // This embedded distributor is available only on non-gplay variants + // where [PushUtils.pushRegistrationToServer] does nothing. + if (hasEmbeddedDistrib) { + UnifiedPush.saveDistributor(context, context.packageName) + registerUnifiedPushForAllAccounts(context, accountManager, null) + } else { + PushUtils.pushRegistrationToServer(accountManager, proxyPushToken) } - PushUtils.pushRegistrationToServer(accountManager, proxyPushToken) } } @@ -281,4 +306,4 @@ object CommonPushUtils { return false } } -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c53c4fe4169..418ed45b4b06 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ coilVersion = "2.7.0" commonsHttpclient = "3.1" commonsIoVersion = "2.21.0" composeBom = "2026.02.00" +unifiedpushEmbeddedFcmDistributorVersion = "3.1.0-rc1" unifiedPushConnectorVersion = "3.3.0" conscryptAndroidVersion = "2.5.3" constraintlayoutVersion = "2.2.1" @@ -98,6 +99,7 @@ appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appCompatV cardview = { module = "androidx.cardview:cardview", version.ref = "cardviewVersion" } core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidxTestVersion" } document-scanning-android-sdk = { module = "com.github.Hazzatur:Document-Scanning-Android-SDK", version.ref = "documentScannerVersion" } +unifiedpush-embedded-fcm-distributor = { module = "org.unifiedpush.android:embedded-fcm-distributor", version.ref = "unifiedpushEmbeddedFcmDistributorVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtxVersion" } exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterfaceVersion" } material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCoreVersion" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 9abd37ec7aca..f1d74a7be24a 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -298,6 +298,7 @@ +