From dee39e7d75a3db38ee8ca0ab37abeb5268c11645 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 9 Dec 2025 17:14:22 +0100 Subject: [PATCH 01/45] chore(deps): Restrict jitpack content Signed-off-by: sim --- build.gradle | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 06510ba77e..6fddc5f593 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,12 @@ allprojects { repositories { google() mavenCentral() - maven { url = 'https://jitpack.io' } + maven { + url = 'https://jitpack.io' + content { + includeGroupByRegex("com\\.github\\..*") + } + } } } From ac79feef677b14995ecb06a8cfd9316082975a9e Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 09:51:42 +0100 Subject: [PATCH 02/45] Add UnifiedPush lib Signed-off-by: sim --- app/build.gradle | 14 +++++++++++++- gradle/verification-metadata.xml | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index a2424e3258..160efc7e3d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -182,6 +182,13 @@ configurations.configureEach { exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' exclude group: 'org.jetbrains', module: 'annotations-java5' // via prism4j, already using annotations explicitly + String protobufJava = "com.google.protobuf:protobuf-java:4.28.2" + resolutionStrategy { + force(protobufJava) + dependencySubstitution { + substitute module("com.google.protobuf:protobuf-javalite") using module(protobufJava) + } + } } dependencies { @@ -261,6 +268,9 @@ dependencies { implementation 'com.github.lukaspili.autodagger2:autodagger2:1.1' kapt 'com.github.lukaspili.autodagger2:autodagger2-compiler:1.1' compileOnly 'javax.annotation:javax.annotation-api:1.3.2' + // Until https://github.com/google/dagger/pull/5062 + kapt 'org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0' + implementation 'org.greenrobot:eventbus:3.3.1' implementation 'net.zetetic:sqlcipher-android:4.13.0' @@ -320,6 +330,8 @@ dependencies { gplayImplementation 'com.google.android.gms:play-services-base:18.10.0' gplayImplementation "com.google.firebase:firebase-messaging:25.0.1" + implementation 'org.unifiedpush.android:connector:3.3.1' + //compose implementation(platform("androidx.compose:compose-bom:2026.02.00")) implementation("androidx.compose.ui:ui") @@ -422,4 +434,4 @@ detekt { ksp { arg('room.schemaLocation', "$projectDir/schemas") -} \ No newline at end of file +} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index ad2df26d83..40ec208d0b 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -225,6 +225,7 @@ + @@ -235,6 +236,7 @@ + From 1934b5f975fe60e5a3ddfd8c9a7e5b09e967e75d Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 10:23:42 +0100 Subject: [PATCH 03/45] Add webpush capability Signed-off-by: sim --- .../main/java/com/nextcloud/talk/data/user/model/User.kt | 3 +++ .../models/json/capabilities/NotificationsCapability.kt | 6 ++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt b/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt index a94ec01044..34443960e5 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt @@ -35,6 +35,9 @@ data class User( var scheduledForDeletion: Boolean = FALSE ) : Parcelable { + val hasWebPushCapability: Boolean + get() = capabilities?.notificationsCapability?.push?.contains("webpush") == true + fun getCredentials(): String = ApiUtils.getCredentials(username, token)!! fun hasSpreedFeatureCapability(capabilityName: String): Boolean { diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt index 957abe921e..1f2d7d7f19 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt @@ -18,8 +18,10 @@ import kotlinx.serialization.Serializable @Serializable data class NotificationsCapability( @JsonField(name = ["ocs-endpoints"]) - var features: List? + var features: List?, + @JsonField(name = ["push"]) + var push: List? ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' - constructor() : this(null) + constructor() : this(null, null) } From 965b6b7850478ab024b85e85b1e58aa3cae7ccbb Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 10:34:08 +0100 Subject: [PATCH 04/45] Add webpush requests Signed-off-by: sim --- .../java/com/nextcloud/talk/api/NcApi.java | 27 +++++++++++++++++++ .../nextcloud/talk/models/json/push/Vapid.kt | 23 ++++++++++++++++ .../talk/models/json/push/VapidOCS.kt | 26 ++++++++++++++++++ .../talk/models/json/push/VapidOverall.kt | 23 ++++++++++++++++ .../java/com/nextcloud/talk/utils/ApiUtils.kt | 7 +++++ 5 files changed, 106 insertions(+) create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt create mode 100644 app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index 972c0b1f73..c6eab369cc 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -27,6 +27,7 @@ import com.nextcloud.talk.models.json.participants.ParticipantsOverall; import com.nextcloud.talk.models.json.participants.TalkBanOverall; import com.nextcloud.talk.models.json.push.PushRegistrationOverall; +import com.nextcloud.talk.models.json.push.VapidOverall; import com.nextcloud.talk.models.json.reactions.ReactionsOverall; import com.nextcloud.talk.models.json.reminder.ReminderOverall; import com.nextcloud.talk.models.json.search.ContactsByNumberOverall; @@ -270,6 +271,32 @@ Observable setUserData(@Header("Authorization") String authoriza @GET Observable getServerStatus(@Url String url); + @GET + Observable getVapidKey( + @Header("Authorization") String authorization, + @Url String url); + + @FormUrlEncoded + @POST + Observable registerWebPush( + @Header("Authorization") String authorization, + @Url String url, + @Field("endpoint") String endpoint, + @Field("uaPublicKey") String uaPublicKey, + @Field("auth") String auth, + @Field("appTypes") String appTypes); + + @FormUrlEncoded + @POST + Observable activateWebPush( + @Header("Authorization") String authorization, + @Url String url, + @Field("activationToken") String activationToken); + + @DELETE + Observable unregisterWebPush( + @Header("Authorization") String authorization, + @Url String url); /* QueryMap items are as follows: diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt new file mode 100644 index 0000000000..5197663293 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.push + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class Vapid( + @JsonField(name = ["vapid"]) + var vapid: String? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt new file mode 100644 index 0000000000..080516b5fc --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.push + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.generic.GenericMeta +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class VapidOCS( + @JsonField(name = ["meta"]) + var meta: GenericMeta?, + @JsonField(name = ["data"]) + var data: Vapid? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt new file mode 100644 index 0000000000..247514db1c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.push + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class VapidOverall( + @JsonField(name = ["ocs"]) + var ocs: VapidOCS? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index 5a06092c37..f7f7a6f1f0 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -393,6 +393,13 @@ object ApiUtils { } @JvmStatic + fun getUrlForVapid(baseUrl: String): String = "$baseUrl$OCS_API_VERSION/apps/notifications/api/v2/webpush/vapid" + @JvmStatic + fun getUrlForWebPush(baseUrl: String): String = "$baseUrl$OCS_API_VERSION/apps/notifications/api/v2/webpush" + @JvmStatic + fun getUrlForWebPushActivation(baseUrl: String): String = + "$baseUrl$OCS_API_VERSION/apps/notifications/api/v2/webpush/activate" + @JvmStatic fun getUrlNextcloudPush(baseUrl: String): String = "$baseUrl$OCS_API_VERSION/apps/notifications/api/v2/push" @JvmStatic From e6ef9dc0df6a58c84d3d2789329d25f551d32a13 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 11:39:07 +0100 Subject: [PATCH 05/45] Add UnifiedPush switch in settings Signed-off-by: sim --- .../talk/settings/SettingsActivity.kt | 26 ++++++++++++++ .../utils/preferences/AppPreferences.java | 4 +++ .../utils/preferences/AppPreferencesImpl.kt | 13 +++++++ app/src/main/res/layout/activity_settings.xml | 35 +++++++++++++++++++ app/src/main/res/values/strings.xml | 3 ++ 5 files changed, 81 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 1de5203983..f2fccac2e0 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -100,6 +100,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody +import org.unifiedpush.android.connector.UnifiedPush import retrofit2.HttpException import java.net.URI import java.net.URISyntaxException @@ -308,11 +309,36 @@ class SettingsActivity : } private fun setupNotificationSettings() { + setupUnifiedPushSettings() setupNotificationSoundsSettings() setupNotificationPermissionSettings() setupServerNotificationAppCheck() } + private fun setupUnifiedPushSettings() { + // If any user doesn't support web push, or there is no UnifiedPush + // service (distributor) available: hide the feature. + // + // We could provide the feature as soon as one user supports web push, + // but for simplicity (UX & dev), and at least in a first step: + // we require that all the users support webpush + if ( + UnifiedPush.getDistributors(this).isEmpty() || + userManager.users.blockingGet().any { + !it.hasWebPushCapability + } + ) { + binding.settingsUnifiedpushSwitch.visibility = View.GONE + } else { + binding.settingsUnifiedpushSwitch.visibility = View.VISIBLE + binding.settingsUnifiedpushSwitch.isChecked = appPreferences.useUnifiedPush + binding.settingsUnifiedpushSwitch.setOnClickListener { + val checked = binding.settingsUnifiedpushSwitch.isChecked + appPreferences.useUnifiedPush = checked + } + } + } + @SuppressLint("StringFormatInvalid") @Suppress("LongMethod") private fun setupNotificationPermissionSettings() { diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index 43f0e5e965..bea129d000 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -68,6 +68,10 @@ public interface AppPreferences { void removePushToken(); + boolean getUseUnifiedPush(); + + void setUseUnifiedPush(boolean value); + String getTemporaryClientCertAlias(); void setTemporaryClientCertAlias(String alias); diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index adc8fb1f35..e2879835d0 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -143,6 +143,18 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { pushToken = "" } + override fun getUseUnifiedPush(): Boolean = + runBlocking { + async { readBoolean(USE_UNIFIEDPUSH).first() } + }.getCompleted() + + override fun setUseUnifiedPush(value: Boolean) = + runBlocking { + async { + writeBoolean(USE_UNIFIEDPUSH, value) + } + } + override fun getPushTokenLatestGeneration(): Long = runBlocking { async { readLong(PUSH_TOKEN_LATEST_GENERATION).first() } @@ -599,6 +611,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val PUSH_TOKEN = "push_token" const val PUSH_TOKEN_LATEST_GENERATION = "push_token_latest_generation" const val PUSH_TOKEN_LATEST_FETCH = "push_token_latest_fetch" + const val USE_UNIFIEDPUSH = "use_unifiedpush" const val TEMP_CLIENT_CERT_ALIAS = "tempClientCertAlias" const val CALL_RINGTONE = "call_ringtone" const val MESSAGE_RINGTONE = "message_ringtone" diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index a3191b8157..ebd8935fc7 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -204,6 +204,41 @@ android:textSize="@dimen/headline_text_size" android:textStyle="bold"/> + + + + + + + + + + + + Light Dark Privacy + Enable UnifiedPush + Receive push notifications with an external + UnifiedPush service Screen lock Lock %1$s with Android screen lock or supported biometric method screen_lock From 2c8a649ff8510cc25dc8978a0d4adc3c4df6b363 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 14:10:25 +0100 Subject: [PATCH 06/45] Show notif permissions for UnifiedPush too Signed-off-by: sim --- .../talk/settings/SettingsActivity.kt | 32 +++++++++++++------ app/src/main/res/layout/activity_settings.xml | 16 +++++++++- app/src/main/res/values/strings.xml | 1 + 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index f2fccac2e0..a46bfd2043 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -315,6 +315,11 @@ class SettingsActivity : setupServerNotificationAppCheck() } + private fun showUnifiedPushToggle(): Boolean { + return UnifiedPush.getDistributors(this).isNotEmpty() && + userManager.users.blockingGet().all { it.hasWebPushCapability } + } + private fun setupUnifiedPushSettings() { // If any user doesn't support web push, or there is no UnifiedPush // service (distributor) available: hide the feature. @@ -322,12 +327,7 @@ class SettingsActivity : // We could provide the feature as soon as one user supports web push, // but for simplicity (UX & dev), and at least in a first step: // we require that all the users support webpush - if ( - UnifiedPush.getDistributors(this).isEmpty() || - userManager.users.blockingGet().any { - !it.hasWebPushCapability - } - ) { + if (!showUnifiedPushToggle()) { binding.settingsUnifiedpushSwitch.visibility = View.GONE } else { binding.settingsUnifiedpushSwitch.visibility = View.VISIBLE @@ -335,6 +335,7 @@ class SettingsActivity : binding.settingsUnifiedpushSwitch.setOnClickListener { val checked = binding.settingsUnifiedpushSwitch.isChecked appPreferences.useUnifiedPush = checked + setupNotificationPermissionSettings() } } } @@ -342,8 +343,10 @@ class SettingsActivity : @SuppressLint("StringFormatInvalid") @Suppress("LongMethod") private fun setupNotificationPermissionSettings() { - if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { - binding.settingsGplayOnlyWrapper.visibility = View.VISIBLE + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable || appPreferences.useUnifiedPush) { + binding.settingsPushOnlyWrapper.visibility = View.VISIBLE + binding.settingsGplayNotAvailable.visibility = View.GONE + binding.settingsPushNotAvailable.visibility = View.GONE setTroubleshootingClickListenersIfNecessary() @@ -425,8 +428,17 @@ class SettingsActivity : binding.settingsNotificationsPermissionWrapper.visibility = View.GONE } } else { - binding.settingsGplayOnlyWrapper.visibility = View.GONE - binding.settingsGplayNotAvailable.visibility = View.VISIBLE + binding.settingsPushOnlyWrapper.visibility = View.GONE + // Shows "UnifiedPush is disabled and Google Play services are not available." if we offer UnifiedPush + // Else "Google Play services are not available" (if any account doesn't support webpush yet, or no + // distrib are installed) + if (showUnifiedPushToggle()) { + binding.settingsGplayNotAvailable.visibility = View.GONE + binding.settingsPushNotAvailable.visibility = View.VISIBLE + } else { + binding.settingsGplayNotAvailable.visibility = View.VISIBLE + binding.settingsPushNotAvailable.visibility = View.GONE + } } } diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index ebd8935fc7..74e0cc304f 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -240,7 +240,7 @@ @@ -326,6 +326,20 @@ android:text="@string/nc_diagnose_gplay_available_no"/> + + + + Google Play services Google Play services are available Google Play services are not available. Notifications are not supported + UnifiedPush is disabled and Google Play services are not available. Notifications are not supported Battery settings Battery optimization is enabled which might cause issues. You should disable battery optimization! Battery optimization is ignored, all fine From 862b9fe01bfe91855127776e4df53c485c92c894 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 15:51:39 +0100 Subject: [PATCH 07/45] Add UnifiedPush to diagnose activity Signed-off-by: sim --- .../talk/diagnose/DiagnoseActivity.kt | 53 +++++++++++++++++-- .../diagnose/DiagnoseContentComposable.kt | 4 +- app/src/main/res/values/strings.xml | 7 +++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt index 22a112ddc8..a9edee6b4d 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt @@ -53,6 +53,7 @@ import com.nextcloud.talk.utils.PushUtils.Companion.LATEST_PUSH_REGISTRATION_AT_ import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils +import org.unifiedpush.android.connector.UnifiedPush import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -79,6 +80,11 @@ class DiagnoseActivity : BaseActivity() { private var isGooglePlayServicesAvailable: Boolean = false + private var nUnifiedPushServices = 0 + private var offerUnifiedPush: Boolean = false + private var useUnifiedPush: Boolean = false + private var unifiedPushService: String = "" + sealed class DiagnoseElement { data class DiagnoseHeadline(val headline: String) : DiagnoseElement() data class DiagnoseEntry(val key: String, val value: String) : DiagnoseElement() @@ -97,6 +103,11 @@ class DiagnoseActivity : BaseActivity() { val colorScheme = viewThemeUtils.getColorScheme(this) isGooglePlayServicesAvailable = ClosedInterfaceImpl().isGooglePlayServicesAvailable + nUnifiedPushServices = UnifiedPush.getDistributors(this).size + offerUnifiedPush = nUnifiedPushServices > 0 && + userManager.users.blockingGet().all { it.hasWebPushCapability } + useUnifiedPush = appPreferences.useUnifiedPush + unifiedPushService = UnifiedPush.getAckDistributor(this) ?: "N/A" setContent { val backgroundColor = colorResource(id = R.color.bg_default) @@ -149,7 +160,7 @@ class DiagnoseActivity : BaseActivity() { viewState = viewState, onTestPushClick = { diagnoseViewModel.fetchTestPushResult() }, onDismissDialog = { diagnoseViewModel.dismissDialog() }, - isGooglePlayServicesAvailable = isGooglePlayServicesAvailable, + showTestPushButton = isGooglePlayServicesAvailable || useUnifiedPush, isOnline = isOnline ) } @@ -251,9 +262,13 @@ class DiagnoseActivity : BaseActivity() { } else { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnose_gplay_available_title), - value = context.resources.getString(R.string.nc_diagnose_gplay_available_no) + value = context.resources.getString(R.string.nc_diagnose_gplay_available_no_short) ) } + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_unifiedpush_available_title), + value = getString(R.string.nc_diagnose_unifiedpush_available_n).format(nUnifiedPushServices) + ) } @SuppressLint("SetTextI18n") @@ -276,7 +291,21 @@ class DiagnoseActivity : BaseActivity() { value = BuildConfig.FLAVOR ) - if (isGooglePlayServicesAvailable) { + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_offer_unifiedpush), + value = translateBoolean(offerUnifiedPush) + ) + + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_use_unifiedpush), + value = translateBoolean(useUnifiedPush) + ) + + if (useUnifiedPush) { + setupAppValuesForPush() + setupAppValuesForUnifiedPush() + } else if (isGooglePlayServicesAvailable) { + setupAppValuesForPush() setupAppValuesForGooglePlayServices() } @@ -286,8 +315,7 @@ class DiagnoseActivity : BaseActivity() { ) } - @Suppress("Detekt.LongMethod") - private fun setupAppValuesForGooglePlayServices() { + private fun setupAppValuesForPush() { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnose_battery_optimization_title), value = if (PowerManagerUtils().isIgnoringBatteryOptimizations()) { @@ -324,7 +352,17 @@ class DiagnoseActivity : BaseActivity() { NotificationUtils.isMessagesNotificationChannelEnabled(this) ) ) + } + private fun setupAppValuesForUnifiedPush() { + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_unifiedpush_service), + value = unifiedPushService + ) + } + + @Suppress("Detekt.LongMethod") + private fun setupAppValuesForGooglePlayServices() { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnose_firebase_push_token_title), value = if (appPreferences.pushToken.isNullOrEmpty()) { @@ -389,6 +427,11 @@ class DiagnoseActivity : BaseActivity() { translateBoolean(currentUser.capabilities?.notificationsCapability?.features?.isNotEmpty()) ) + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_server_supports_webpush), + value = translateBoolean(currentUser.hasWebPushCapability) + ) + if (isGooglePlayServicesAvailable) { setupPushRegistrationDiagnose() } diff --git a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseContentComposable.kt b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseContentComposable.kt index 29c1be2127..103b887394 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseContentComposable.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseContentComposable.kt @@ -59,7 +59,7 @@ fun DiagnoseContentComposable( viewState: NotificationUiState, onTestPushClick: () -> Unit, onDismissDialog: () -> Unit, - isGooglePlayServicesAvailable: Boolean, + showTestPushButton: Boolean, isOnline: Boolean ) { val context = LocalContext.current @@ -102,7 +102,7 @@ fun DiagnoseContentComposable( } } } - if (isGooglePlayServicesAvailable && isOnline) { + if (showTestPushButton && isOnline) { ShowTestPushButton(onTestPushClick) } ShowNotificationData(isLoading, showDialog, context, viewState, onDismissDialog) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80c0548162..27a7ad5c89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -208,6 +208,13 @@ How to translate with transifex: Registered users Google Play services Google Play services are available + Google Play services are not available. + UnifiedPush services + %d service(s) available + Offer UnifiedPush + Use UnifiedPush + UnifiedPush service + Server supports webpush? Google Play services are not available. Notifications are not supported UnifiedPush is disabled and Google Play services are not available. Notifications are not supported Battery settings From de7b1efced6a7caca2424604adbfb7fba3df4a3d Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 17:30:41 +0100 Subject: [PATCH 08/45] Register for push notifications to UnifiedPush and server Signed-off-by: sim --- .../talk/jobs/PushRegistrationWorker.java | 75 ------ .../talk/jobs/PushRegistrationWorker.kt | 229 ++++++++++++++++++ .../talk/settings/SettingsActivity.kt | 9 + .../nextcloud/talk/utils/UnifiedPushUtils.kt | 96 ++++++++ 4 files changed, 334 insertions(+), 75 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.java create mode 100644 app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.java deleted file mode 100644 index 80eefee650..0000000000 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Andy Scherzinger - * SPDX-FileCopyrightText: 2022 Marcel Hibbe - * SPDX-FileCopyrightText: 2017 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.jobs; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.work.Data; -import androidx.work.Worker; -import androidx.work.WorkerParameters; -import autodagger.AutoInjector; -import okhttp3.CookieJar; -import okhttp3.OkHttpClient; -import retrofit2.Retrofit; - -import com.nextcloud.talk.api.NcApi; -import com.nextcloud.talk.application.NextcloudTalkApplication; -import com.nextcloud.talk.utils.ClosedInterfaceImpl; -import com.nextcloud.talk.utils.PushUtils; - -import java.net.CookieManager; - -import javax.inject.Inject; - -@AutoInjector(NextcloudTalkApplication.class) -public class PushRegistrationWorker extends Worker { - public static final String TAG = "PushRegistrationWorker"; - public static final String ORIGIN = "origin"; - - @Inject - Retrofit retrofit; - - @Inject - OkHttpClient okHttpClient; - - public PushRegistrationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result doWork() { - NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this); - if (new ClosedInterfaceImpl().isGooglePlayServicesAvailable()) { - Data data = getInputData(); - String origin = data.getString("origin"); - Log.d(TAG, "PushRegistrationWorker called via " + origin); - - NcApi ncApi = retrofit - .newBuilder() - .client(okHttpClient - .newBuilder() - .cookieJar(CookieJar.NO_COOKIES) - .build()) - .build() - .create(NcApi.class); - - PushUtils pushUtils = new PushUtils(); - pushUtils.generateRsa2048KeyPair(); - pushUtils.pushRegistrationToServer(ncApi); - - return Result.success(); - } - Log.w(TAG, "executing PushRegistrationWorker doesn't make sense because Google Play Services are not " + - "available"); - return Result.failure(); - } -} diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt new file mode 100644 index 0000000000..545a7f13fd --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -0,0 +1,229 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2022 Marcel Hibbe + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.jobs; + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import androidx.work.Worker +import androidx.work.WorkerParameters +import autodagger.AutoInjector +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.generic.Status +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.ClosedInterfaceImpl +import com.nextcloud.talk.utils.PushUtils +import com.nextcloud.talk.utils.preferences.AppPreferences +import io.reactivex.Observable +import okhttp3.CookieJar +import okhttp3.OkHttpClient +import org.unifiedpush.android.connector.UnifiedPush +import retrofit2.Retrofit +import java.net.CookieManager +import javax.inject.Inject + +@AutoInjector(NextcloudTalkApplication::class) +class PushRegistrationWorker( + context: Context, + workerParams: WorkerParameters +): Worker(context, workerParams) { + @Inject + lateinit var retrofit: Retrofit + + @Inject + lateinit var okHttpClient: OkHttpClient + + @Inject + lateinit var preferences: AppPreferences + + @Inject + lateinit var userManager: UserManager + + lateinit var ncApi: NcApi + + private fun inject() { + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + ncApi = retrofit + .newBuilder() + .client( + okHttpClient + .newBuilder() + .cookieJar(CookieJar.NO_COOKIES) + .build() + ) + .build() + .create(NcApi::class.java) + } + + @SuppressLint("CheckResult") + override fun doWork(): Result { + inject() + val origin = inputData.getString(ORIGIN) + val useUnifiedPush = inputData.getBoolean(USE_UNIFIEDPUSH, defaultUseUnifiedPush()) + Log.d(TAG, "PushRegistrationWorker called via $origin (up=$useUnifiedPush)") + + if (useUnifiedPush) { + registerUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) + // unregister proxy push for user setting up web push for the first time + .flatMap { user -> unregisterProxyPush(user)} + } else { + unregisterUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) + .toList() + .subscribe { _, _ -> + registerProxyPush() + } + } + return Result.success() + } + + private fun defaultUseUnifiedPush(): Boolean = preferences.useUnifiedPush && + // If this is the first registration, we have never called [UnifiedPush.register] + // because it happens after this function + // => we can't be acked by the distributor yet, [UnifiedPush.getAckDistributor] == null + // So we check the SavedDistributor instead + UnifiedPush.getSavedDistributor(applicationContext).also { + if (it == null) { + Log.d(TAG, "No saved distributor found: disabling UnifiedPush") + preferences.useUnifiedPush = false + } + } != null + + /** + * Register proxy push for all accounts with [User.usesProxyPush], set if + * the server doesn't support webpush or if UnifiedPush is disabled + */ + private fun registerProxyPush() { + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + Log.d(TAG, "Registering proxy push") + val pushUtils = PushUtils() + pushUtils.generateRsa2048KeyPair() + pushUtils.pushRegistrationToServer(ncApi) + } + } + + private fun unregisterProxyPush(user: User): Observable? { + return if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + Log.d(TAG, "Unregistering proxy push for ${user.userId}") + ncApi.unregisterDeviceForNotificationsWithNextcloud( + user.getCredentials(), + ApiUtils.getUrlNextcloudPush(user.baseUrl!!) + ).flatMap { + val pushConfig = user.pushConfigurationState!! + val queryMap = hashMapOf( + "deviceIdentifier" to pushConfig.deviceIdentifier, + "userPublicKey" to pushConfig.userPublicKey, + "deviceIdentifierSignature" to pushConfig.deviceIdentifierSignature + ) + ncApi.unregisterDeviceForNotificationsWithProxy(ApiUtils.getUrlPushProxy(), queryMap) + } + } else { + null + } + } + + fun unregisterUnifiedPushForAllAccounts( + context: Context, + userManager: UserManager, + ncApi: NcApi + ): Observable { + val obs = userManager.users.blockingGet().mapNotNull { user -> + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return@mapNotNull null + } + UnifiedPush.unregister(context, user.userId!!) + if (user.usesWebPush) { + user.usesWebPush = false + userManager.saveUser(user) + ncApi.unregisterWebPush(user.getCredentials(), ApiUtils.getUrlForWebPush(user.baseUrl!!)) + } else { + return@mapNotNull null + } + } + return Observable.merge(obs) + } + + /** + * 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 + * + * @return Observable not null if user was using proxy push and now use web push + */ + fun registerUnifiedPushForAllAccounts( + context: Context, + userManager: UserManager, + ncApi: NcApi + ): Observable { + val obs = userManager.users.blockingGet().map { user -> + registerUnifiedPushForAccount(context, ncApi, user) + } + return Observable.merge(obs) + // We do not update the user push proxy setting on error + .flatMap { res -> + val user = res.first + val wasUsingProxyPush = user.usesProxyPush + user.usesWebPush = !res.second + userManager.saveUser(user) + Log.d(TAG, "User ${user.userId} updated: wasUsingProxy=$wasUsingProxyPush, now=${user.usesProxyPush}") + if (wasUsingProxyPush && !user.usesProxyPush) { + Observable.just(user) + } else { + Observable.just(null) + } + } + } + + /** + * 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 `Observable`, true if registration succeed, false if server doesn't support web push + */ + private fun registerUnifiedPushForAccount( + context: Context, + ncApi: NcApi, + user: User + ): Observable>? { + if (user.hasWebPushCapability) { + Log.d(TAG, "Registering web push for ${user.userId}") + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return null + } + return ncApi.getVapidKey(user.getCredentials(),ApiUtils.getUrlForVapid(user.baseUrl!!)) + .flatMap { ocs -> + ocs.ocs?.data?.vapid?.let { vapid -> + UnifiedPush.register( + context, + instance = user.userId!!, + messageForDistributor = user.userId, + vapid = vapid + ) + Observable.just(user to true) + } + } + } else { + Log.d(TAG, "${user.userId}'s server doesn't support web push") + return Observable.just(user to false) + } + } + + companion object { + const val TAG = "PushRegistrationWorker" + const val ORIGIN = "origin" + const val USE_UNIFIEDPUSH = "use_unifiedpush" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index a46bfd2043..199d4f297f 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -83,6 +83,7 @@ import com.nextcloud.talk.utils.NotificationUtils.getCallRingtoneUri import com.nextcloud.talk.utils.NotificationUtils.getMessageRingtoneUri import com.nextcloud.talk.utils.SecurityUtils import com.nextcloud.talk.utils.SpreedFeatures +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SCROLL_TO_NOTIFICATION_CATEGORY import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils @@ -336,6 +337,14 @@ class SettingsActivity : val checked = binding.settingsUnifiedpushSwitch.isChecked appPreferences.useUnifiedPush = checked setupNotificationPermissionSettings() + if (checked) { + UnifiedPushUtils.useDefaultDistributor(this) { distrib -> + Log.d(TAG, "Registered to $distrib") + // TODO summary for service change + } + } else { + UnifiedPushUtils.disableExternalUnifiedPush(this) + } } } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt new file mode 100644 index 0000000000..e1be9b6024 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -0,0 +1,96 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.utils + +import android.app.Activity +import android.content.Context +import android.util.Log +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.nextcloud.talk.jobs.PushRegistrationWorker +import org.unifiedpush.android.connector.UnifiedPush + +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 userManager: Used to register all accounts + * @param ncApi: API + * @param callback: run with the push service name if available + */ + @JvmStatic + fun useDefaultDistributor( + activity: Activity, + callback: (String?) -> Unit + ) { + Log.d(TAG, "Using default UnifiedPush distributor") + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity as Context) { res -> + if (res) { + enqueuePushWorker(activity, true, "useDefaultDistributor") + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + callback(null) + } + } + } + + /** + * 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, + callback: (String?) -> Unit + ) { + Log.d(TAG, "Picking another UnifiedPush distributor") + UnifiedPush.tryPickDistributor(activity as Context) { res -> + if (res) { + enqueuePushWorker(activity, true, "useDefaultDistributor") + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + callback(null) + } + } + }*/ + + /** + * Disable UnifiedPush and try to register with proxy push again + */ + @JvmStatic + fun disableExternalUnifiedPush( + context: Context + ) { + enqueuePushWorker(context, false, "disableExternalUnifiedPush") + } + + private fun enqueuePushWorker(context: Context, useUnifiedPush: Boolean, origin: String) { + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushUtils#$origin") + .putBoolean(PushRegistrationWorker.USE_UNIFIEDPUSH, useUnifiedPush) + .build() + val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(context).enqueue(pushRegistrationWork) + + } +} From e31c6f3779b6d09466346d0601db67ef8cb9134d Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 17:54:14 +0100 Subject: [PATCH 09/45] Fix settings activity Signed-off-by: sim --- .../com/nextcloud/talk/settings/SettingsActivity.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 199d4f297f..ac9b6412f5 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -329,13 +329,14 @@ class SettingsActivity : // but for simplicity (UX & dev), and at least in a first step: // we require that all the users support webpush if (!showUnifiedPushToggle()) { - binding.settingsUnifiedpushSwitch.visibility = View.GONE + binding.settingsUnifiedpush.visibility = View.GONE } else { - binding.settingsUnifiedpushSwitch.visibility = View.VISIBLE + binding.settingsUnifiedpush.visibility = View.VISIBLE binding.settingsUnifiedpushSwitch.isChecked = appPreferences.useUnifiedPush - binding.settingsUnifiedpushSwitch.setOnClickListener { - val checked = binding.settingsUnifiedpushSwitch.isChecked + binding.settingsUnifiedpush.setOnClickListener { + val checked = !appPreferences.useUnifiedPush appPreferences.useUnifiedPush = checked + binding.settingsUnifiedpushSwitch.isChecked = checked setupNotificationPermissionSettings() if (checked) { UnifiedPushUtils.useDefaultDistributor(this) { distrib -> @@ -819,6 +820,7 @@ class SettingsActivity : binding.run { listOf( settingsShowNotificationWarningSwitch, + settingsUnifiedpushSwitch, settingsScreenLockSwitch, settingsScreenSecuritySwitch, settingsIncognitoKeyboardSwitch, From 0ad2291f59e3feee0e3df3d1c65232ec9ec886ac Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 11:03:53 +0100 Subject: [PATCH 10/45] Fix PushRegistrationWorker Signed-off-by: sim --- .../talk/jobs/PushRegistrationWorker.kt | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 545a7f13fd..c64ca9ae80 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -6,7 +6,7 @@ * SPDX-FileCopyrightText: 2017 Mario Danic * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.jobs; +package com.nextcloud.talk.jobs import android.annotation.SuppressLint import android.content.Context @@ -75,11 +75,21 @@ class PushRegistrationWorker( registerUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) // unregister proxy push for user setting up web push for the first time .flatMap { user -> unregisterProxyPush(user)} + .toList() + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while registering for UnifiedPush") + e.printStackTrace() + } + } } else { unregisterUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) .toList() - .subscribe { _, _ -> - registerProxyPush() + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while unregistering from UnifiedPush") + e.printStackTrace() + } ?: registerProxyPush() } } return Result.success() @@ -110,7 +120,7 @@ class PushRegistrationWorker( } } - private fun unregisterProxyPush(user: User): Observable? { + private fun unregisterProxyPush(user: User): Observable { return if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { Log.d(TAG, "Unregistering proxy push for ${user.userId}") ncApi.unregisterDeviceForNotificationsWithNextcloud( @@ -126,7 +136,7 @@ class PushRegistrationWorker( ncApi.unregisterDeviceForNotificationsWithProxy(ApiUtils.getUrlPushProxy(), queryMap) } } else { - null + Observable.empty() } } @@ -165,7 +175,7 @@ class PushRegistrationWorker( context: Context, userManager: UserManager, ncApi: NcApi - ): Observable { + ): Observable { val obs = userManager.users.blockingGet().map { user -> registerUnifiedPushForAccount(context, ncApi, user) } @@ -174,13 +184,13 @@ class PushRegistrationWorker( .flatMap { res -> val user = res.first val wasUsingProxyPush = user.usesProxyPush - user.usesWebPush = !res.second + user.usesProxyPush = !res.second userManager.saveUser(user) Log.d(TAG, "User ${user.userId} updated: wasUsingProxy=$wasUsingProxyPush, now=${user.usesProxyPush}") if (wasUsingProxyPush && !user.usesProxyPush) { Observable.just(user) } else { - Observable.just(null) + Observable.empty() } } } @@ -196,12 +206,12 @@ class PushRegistrationWorker( context: Context, ncApi: NcApi, user: User - ): Observable>? { + ): Observable> { if (user.hasWebPushCapability) { Log.d(TAG, "Registering web push for ${user.userId}") if (user.userId == null || user.baseUrl == null) { Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") - return null + return Observable.empty() } return ncApi.getVapidKey(user.getCredentials(),ApiUtils.getUrlForVapid(user.baseUrl!!)) .flatMap { ocs -> @@ -213,6 +223,9 @@ class PushRegistrationWorker( vapid = vapid ) Observable.just(user to true) + } ?: let { + Log.d(TAG, "No VAPID key found") + Observable.just(user to false) } } } else { From c3a3dac1ac880620b2c881d307c2c972b5878298 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 14:47:20 +0100 Subject: [PATCH 11/45] Fix API return type for webpush Signed-off-by: sim --- app/src/main/java/com/nextcloud/talk/api/NcApi.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index c6eab369cc..934129321b 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -278,7 +278,7 @@ Observable getVapidKey( @FormUrlEncoded @POST - Observable registerWebPush( + Observable> registerWebPush( @Header("Authorization") String authorization, @Url String url, @Field("endpoint") String endpoint, @@ -288,13 +288,13 @@ Observable registerWebPush( @FormUrlEncoded @POST - Observable activateWebPush( + Observable> activateWebPush( @Header("Authorization") String authorization, @Url String url, @Field("activationToken") String activationToken); @DELETE - Observable unregisterWebPush( + Observable unregisterWebPush( @Header("Authorization") String authorization, @Url String url); From bec4549f01f92833f4ac1de7d6411ad660d94b96 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 14:50:33 +0100 Subject: [PATCH 12/45] Fix web push jobs Signed-off-by: sim --- .../talk/jobs/PushRegistrationWorker.kt | 280 +++++++++++++----- 1 file changed, 208 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index c64ca9ae80..5f4d0e3a8d 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -17,20 +17,32 @@ import autodagger.AutoInjector import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.models.json.generic.Status import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.PushUtils import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observable +import io.reactivex.schedulers.Schedulers import okhttp3.CookieJar import okhttp3.OkHttpClient import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint import retrofit2.Retrofit import java.net.CookieManager import javax.inject.Inject +/** + * Can be used for 4 different things: + * - if inputData contains [USER_ID] and [ACTIVATION_TOKEN]: activate web push for user (on server) and unregister + * for proxy push (on server) (received from [com.nextcloud.talk.services.UnifiedPushService]) + * - if inputData contains [UNIFIEDPUSH_ENDPOINT]: register for web push (on server) + * (received from [com.nextcloud.talk.services.UnifiedPushService]) + * - if inputData contains [USE_UNIFIEDPUSH] or if [AppPreferences.getUseUnifiedPush]: get the server VAPID key and + * register for UnifiedPush to the distributor (on device) + * - if [AppPreferences.getUseUnifiedPush] is false: unregister UnifiedPush (on device) and unregister for web push + * (on server), then register for proxy push (on server) + */ @AutoInjector(NextcloudTalkApplication::class) class PushRegistrationWorker( context: Context, @@ -68,33 +80,127 @@ class PushRegistrationWorker( override fun doWork(): Result { inject() val origin = inputData.getString(ORIGIN) + val userId = inputData.getLong(USER_ID, -1) + val activationToken = inputData.getString(ACTIVATION_TOKEN) + //TODO fix dummy + //val pushEndpoint = inputData.getByteArray(UNIFIEDPUSH_ENDPOINT)?.toPushEndpoint() + val pushEndpoint = inputData.getByteArray(UNIFIEDPUSH_ENDPOINT)?.let { + PushEndpoint("http://dummy", null, false) + } val useUnifiedPush = inputData.getBoolean(USE_UNIFIEDPUSH, defaultUseUnifiedPush()) - Log.d(TAG, "PushRegistrationWorker called via $origin (up=$useUnifiedPush)") - - if (useUnifiedPush) { - registerUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) - // unregister proxy push for user setting up web push for the first time - .flatMap { user -> unregisterProxyPush(user)} - .toList() - .subscribe { _, e -> - e?.let { - Log.d(TAG, "An error occurred while registering for UnifiedPush") - e.printStackTrace() - } - } + if (userId != -1L && activationToken != null) { + Log.d(TAG, "PushRegistrationWorker called via $origin (webPushActivationWork)") + webPushActivationWork(userId, activationToken) + } else if (pushEndpoint != null) { + Log.d(TAG, "PushRegistrationWorker called via $origin (webPushWork)") + webPushWork(pushEndpoint) + } else if (useUnifiedPush) { + Log.d(TAG, "PushRegistrationWorker called via $origin (unifiedPushWork)") + unifiedPushWork() } else { - unregisterUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) - .toList() - .subscribe { _, e -> - e?.let { - Log.d(TAG, "An error occurred while unregistering from UnifiedPush") - e.printStackTrace() - } ?: registerProxyPush() - } + Log.d(TAG, "PushRegistrationWorker called via $origin (proxyPushWork)") + proxyPushWork() } return Result.success() } + /** + * Activate web push for user (on server) and unregister for proxy push (on server) + */ + @SuppressLint("CheckResult") + private fun webPushActivationWork(id: Long, activationToken: String) { + val user = userManager.getUserWithId(id).blockingGet() + activateWebPushForAccount(user, activationToken) + .map { res -> + if (res) { + unregisterProxyPush(user) + } else { + Log.d(TAG, "Couldn't activate web push for user ${user.userId}") + Observable.empty() + } + } + .toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while activating web push, or unregistering proxy push") + e.printStackTrace() + } + } + } + + /** + * Register for web push (on server) + */ + @SuppressLint("CheckResult") + private fun webPushWork(pushEndpoint: PushEndpoint) { + val obs = userManager.users.blockingGet().map { user -> + registerWebPushForAccount(user, pushEndpoint) + } + Observable.merge(obs) + .map { (user, res) -> + if (res) { + Log.d(TAG, "User ${user.userId} registered for web push.") + } else { + Log.w(TAG, "Couldn't register ${user.userId} for web push.") + } + }.toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while registering for web push") + e.printStackTrace() + } + } + } + + /** + * Get VAPID key (on server) and register UnifiedPush to the distributor (on device) + */ + @SuppressLint("CheckResult") + private fun unifiedPushWork() { + val obs = userManager.users.blockingGet().map { user -> + registerUnifiedPushForAccount(user) + } + Observable.merge(obs) + .toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while registering for UnifiedPush") + e.printStackTrace() + } + } + } + + /** + * Unregister for UnifiedPush (on device) and web push (on server), and + * register for proxy push (on server) + */ + @SuppressLint("CheckResult") + private fun proxyPushWork() { + val obs = userManager.users.blockingGet().mapNotNull { user -> + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return@mapNotNull null + } + UnifiedPush.unregister(applicationContext, user.userId!!) + // TODO unregisterWebPushForUser + Observable.empty() + } + Observable.merge(obs) + .toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while unregistering for web push") + e.printStackTrace() + } + // Register proxy push for all account, no matter the result of the web push unregistration + registerProxyPush() + } + } + private fun defaultUseUnifiedPush(): Boolean = preferences.useUnifiedPush && // If this is the first registration, we have never called [UnifiedPush.register] // because it happens after this function @@ -108,8 +214,9 @@ class PushRegistrationWorker( } != null /** - * Register proxy push for all accounts with [User.usesProxyPush], set if - * the server doesn't support webpush or if UnifiedPush is disabled + * Register proxy push for all accounts if the devices support the Play Services + * + * This must not be called when UnifiedPush is enabled. */ private fun registerProxyPush() { if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { @@ -120,6 +227,9 @@ class PushRegistrationWorker( } } + /** + * Unregister on NC server and NC proxy + */ private fun unregisterProxyPush(user: User): Observable { return if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { Log.d(TAG, "Unregistering proxy push for ${user.userId}") @@ -140,59 +250,84 @@ class PushRegistrationWorker( } } - fun unregisterUnifiedPushForAllAccounts( - context: Context, - userManager: UserManager, - ncApi: NcApi - ): Observable { - val obs = userManager.users.blockingGet().mapNotNull { user -> + /** + * Register web push with the unifiedpush endpoint, if the server supports web push + * + * @return `Observable>`, true if registration succeed, false if server doesn't support web push + */ + private fun registerWebPushForAccount( + user: User, + pushEndpoint: PushEndpoint + ): Observable> { + if (user.hasWebPushCapability) { + Log.d(TAG, "Registering web push for ${user.userId}") if (user.userId == null || user.baseUrl == null) { Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") - return@mapNotNull null + return Observable.empty() } - UnifiedPush.unregister(context, user.userId!!) - if (user.usesWebPush) { - user.usesWebPush = false - userManager.saveUser(user) - ncApi.unregisterWebPush(user.getCredentials(), ApiUtils.getUrlForWebPush(user.baseUrl!!)) - } else { - return@mapNotNull null + if (pushEndpoint.pubKeySet == null) { + // Should not happen with default UnifiedPush KeyManager + Log.w(TAG, "Null web push keys for user ${user.userId}, aborting.") + return Observable.empty() + } + return ncApi.registerWebPush( + user.getCredentials(), + ApiUtils.getUrlForWebPush(user.baseUrl!!), + pushEndpoint.url, + pushEndpoint.pubKeySet!!.pubKey, + pushEndpoint.pubKeySet!!.auth, + "talk" + ).map { r -> + return@map when (r.code()) { + 200 -> { + Log.d(TAG, "Web push registration for ${user.userId} was already registered and activated\n") + user to true + } + 201 -> { + Log.d(TAG, "New web push registration for ${user.userId}") + user to true + } + else -> { + Log.d(TAG, "An error occurred while registering web push for ${user.userId} (status=${r.code()})") + user to false + } + } } + } else { + Log.d(TAG, "${user.userId}'s server doesn't support web push") + return Observable.just(user to false) } - return Observable.merge(obs) } - /** - * 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 - * - * @return Observable not null if user was using proxy push and now use web push - */ - fun registerUnifiedPushForAllAccounts( - context: Context, - userManager: UserManager, - ncApi: NcApi - ): Observable { - val obs = userManager.users.blockingGet().map { user -> - registerUnifiedPushForAccount(context, ncApi, user) + private fun activateWebPushForAccount( + user: User, + activationToken: String + ) : Observable { + Log.d(TAG, "Activating web push for ${user.userId}") + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return Observable.empty() } - return Observable.merge(obs) - // We do not update the user push proxy setting on error - .flatMap { res -> - val user = res.first - val wasUsingProxyPush = user.usesProxyPush - user.usesProxyPush = !res.second - userManager.saveUser(user) - Log.d(TAG, "User ${user.userId} updated: wasUsingProxy=$wasUsingProxyPush, now=${user.usesProxyPush}") - if (wasUsingProxyPush && !user.usesProxyPush) { - Observable.just(user) - } else { - Observable.empty() + return ncApi.activateWebPush( + user.getCredentials(), + ApiUtils.getUrlForWebPushActivation(user.baseUrl!!), + activationToken + ).map { r -> + return@map when (r.code()) { + 200 -> { + Log.d(TAG, "Web push registration for ${user.userId} was already activated\n") + true + } + 202 -> { + Log.d(TAG, "Web push registration for ${user.userId} activated") + true + } + else -> { + Log.d(TAG, "An error occurred while registering web push for ${user.userId} (status=${r.code()})") + false } } + } } /** @@ -200,15 +335,13 @@ class PushRegistrationWorker( * * Web push is registered on the nc server when the push endpoint is received * - * @return `Observable`, true if registration succeed, false if server doesn't support web push + * @return `Observable>`, true if registration succeed, false if server doesn't support web push */ private fun registerUnifiedPushForAccount( - context: Context, - ncApi: NcApi, user: User ): Observable> { if (user.hasWebPushCapability) { - Log.d(TAG, "Registering web push for ${user.userId}") + Log.d(TAG, "Registering UnifiedPush for ${user.userId}") if (user.userId == null || user.baseUrl == null) { Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") return Observable.empty() @@ -217,7 +350,7 @@ class PushRegistrationWorker( .flatMap { ocs -> ocs.ocs?.data?.vapid?.let { vapid -> UnifiedPush.register( - context, + applicationContext, instance = user.userId!!, messageForDistributor = user.userId, vapid = vapid @@ -237,6 +370,9 @@ class PushRegistrationWorker( companion object { const val TAG = "PushRegistrationWorker" const val ORIGIN = "origin" + const val USER_ID = "user_id" + const val ACTIVATION_TOKEN = "activation_token" const val USE_UNIFIEDPUSH = "use_unifiedpush" + const val UNIFIEDPUSH_ENDPOINT = "unifiedpush_endpoint" } } From 1c277cff67a07e9f80895d0a52a4cba4d54a8039 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 14:57:01 +0100 Subject: [PATCH 13/45] Add instanceFor function to centralized generation of UP instances for users Signed-off-by: sim --- .../com/nextcloud/talk/jobs/PushRegistrationWorker.kt | 5 +++-- .../java/com/nextcloud/talk/utils/UnifiedPushUtils.kt | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 5f4d0e3a8d..512870d3ff 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -21,6 +21,7 @@ import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.PushUtils +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observable import io.reactivex.schedulers.Schedulers @@ -184,7 +185,7 @@ class PushRegistrationWorker( Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") return@mapNotNull null } - UnifiedPush.unregister(applicationContext, user.userId!!) + UnifiedPush.unregister(applicationContext, UnifiedPushUtils.instanceFor(user)) // TODO unregisterWebPushForUser Observable.empty() } @@ -351,7 +352,7 @@ class PushRegistrationWorker( ocs.ocs?.data?.vapid?.let { vapid -> UnifiedPush.register( applicationContext, - instance = user.userId!!, + instance = UnifiedPushUtils.instanceFor(user), messageForDistributor = user.userId, vapid = vapid ) diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index e1be9b6024..a28e47061c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -13,6 +13,7 @@ import android.util.Log import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.PushRegistrationWorker import org.unifiedpush.android.connector.UnifiedPush @@ -93,4 +94,11 @@ object UnifiedPushUtils { WorkManager.getInstance(context).enqueue(pushRegistrationWork) } + + /** + * Get UnifiedPush instance for user + * + * This is simply the [User.id] (long) in String, but it allows defining it in a single place + */ + fun instanceFor(user: User): String = "${user.id}" } From b2c702f2bd4be781b209eb55d75cdf5dfe340b4c Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 15:35:01 +0100 Subject: [PATCH 14/45] Add UnifiedPushService, register new endpoint and activate web push Signed-off-by: sim --- app/src/main/AndroidManifest.xml | 7 ++ .../talk/jobs/PushRegistrationWorker.kt | 7 +- .../talk/services/UnifiedPushService.kt | 76 +++++++++++++++++++ .../nextcloud/talk/utils/UnifiedPushUtils.kt | 28 ++++++- 4 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a6ef2dc6bb..285c6cb7ac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -312,6 +312,13 @@ android:exported="false" android:foregroundServiceType="microphone|camera" /> + + + + + + + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.services + +import android.util.Log +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.nextcloud.talk.jobs.PushRegistrationWorker +import com.nextcloud.talk.utils.UnifiedPushUtils.toByteArray +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 + +class UnifiedPushService: PushService() { + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + Log.d(TAG, "New endpoint for $instance") + val endpointBA = endpoint.toByteArray() ?: run { + Log.w(TAG, "Couldn't serialize endpoint!") + return + } + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushService#onNewEndpoint") + .putLong(PushRegistrationWorker.USER_ID, instance.toLong()) + .putByteArray(PushRegistrationWorker.UNIFIEDPUSH_ENDPOINT, endpointBA) + .build() + val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(this).enqueue(pushRegistrationWork) + } + + override fun onMessage(message: PushMessage, instance: String) { + Log.d(TAG, "New message for $instance") + try { + val mObj = JSONObject(message.content.toString(Charsets.UTF_8)) + val token = mObj.getString("activationToken") + onActivationToken(token, instance) + } catch (_: JSONException) { + // Messages are encrypted following RFC8291, and UnifiedPush lib handle the decryption itself: + // message.content is the cleartext + } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + Log.d(TAG, "Registration failed for $instance") + } + + override fun onUnregistered(instance: String) { + Log.d(TAG, "$instance unregistered") + } + + private fun onActivationToken(activationToken: String, instance: String) { + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushService#onActivationToken") + .putLong(PushRegistrationWorker.USER_ID, instance.toLong()) + .putString(PushRegistrationWorker.ACTIVATION_TOKEN, activationToken) + .build() + val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(this).enqueue(pushRegistrationWork) + } + + companion object { + const val TAG = "UnifiedPushService" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index a28e47061c..974561d0cd 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.utils import android.app.Activity import android.content.Context +import android.os.Parcel import android.util.Log import androidx.work.Data import androidx.work.OneTimeWorkRequest @@ -16,6 +17,7 @@ import androidx.work.WorkManager import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.PushRegistrationWorker import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint object UnifiedPushUtils { private val TAG: String = UnifiedPushUtils::class.java.getSimpleName() @@ -92,7 +94,6 @@ object UnifiedPushUtils { .setInputData(data) .build() WorkManager.getInstance(context).enqueue(pushRegistrationWork) - } /** @@ -101,4 +102,29 @@ object UnifiedPushUtils { * This is simply the [User.id] (long) in String, but it allows defining it in a single place */ fun instanceFor(user: User): String = "${user.id}" + + fun PushEndpoint.toByteArray(): ByteArray? { + val parcel = Parcel.obtain() + return try { + writeToParcel(parcel, 0) + parcel.marshall() + } catch (_: Exception) { + null + } finally { + parcel.recycle() + } + } + + fun ByteArray.toPushEndpoint(): PushEndpoint? { + val parcel = Parcel.obtain() + return try { + parcel.unmarshall(this, 0, size) + parcel.setDataPosition(0) // Reset Parcel position to read from the start + PushEndpoint.createFromParcel(parcel) + } catch (_: Exception) { + null + } finally { + parcel.recycle() + } + } } From bbcc85c1e6e4f6c459f5d7fca78962fc91398aaf Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 15:41:13 +0100 Subject: [PATCH 15/45] Unregister from web push when using proxyPush Signed-off-by: sim --- .../talk/jobs/PushRegistrationWorker.kt | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 973b615c8b..302a47875d 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -182,9 +182,12 @@ class PushRegistrationWorker( Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") return@mapNotNull null } - UnifiedPush.unregister(applicationContext, UnifiedPushUtils.instanceFor(user)) - // TODO unregisterWebPushForUser - Observable.empty() + if (user.hasWebPushCapability) { + UnifiedPush.unregister(applicationContext, UnifiedPushUtils.instanceFor(user)) + unregisterWebPushForAccount(user) + } else { + Observable.empty() + } } Observable.merge(obs) .toList() @@ -328,6 +331,21 @@ class PushRegistrationWorker( } } + private fun unregisterWebPushForAccount( + user: User + ) : Observable { + Log.d(TAG, "Unregistering web push for ${user.userId}") + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return Observable.empty() + } + return ncApi.unregisterWebPush( + user.getCredentials(), + ApiUtils.getUrlForWebPush(user.baseUrl!!) + ).map { true } + + } + /** * Register UnifiedPush with the server VAPID key if the server supports web push * From 94ee433c101065fef6e430a7c54f791f84781601 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 16:44:07 +0100 Subject: [PATCH 16/45] Process push notifications with UnifiedPush Signed-off-by: sim --- .../nextcloud/talk/jobs/NotificationWorker.kt | 69 ++++++++++--------- .../talk/services/UnifiedPushService.kt | 10 +++ .../nextcloud/talk/utils/bundle/BundleKeys.kt | 2 + 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index b0a667ee55..2f4b93aa37 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -48,7 +48,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.callnotification.CallNotificationActivity import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource -import com.nextcloud.talk.models.SignatureVerification +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage import com.nextcloud.talk.models.json.conversations.ConversationEnums @@ -133,7 +133,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor private lateinit var credentials: String private lateinit var ncApi: NcApi private lateinit var pushMessage: DecryptedPushMessage - private lateinit var signatureVerification: SignatureVerification + private lateinit var user: User private var context: Context? = null private var conversationType: String? = "one2one" private lateinit var notificationManager: NotificationManagerCompat @@ -156,12 +156,12 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor Log.d(TAG, "pushMessage.timestamp: " + pushMessage.timestamp) if (pushMessage.delete) { - cancelNotification(context, signatureVerification.user!!, pushMessage.notificationId) + cancelNotification(context, user, pushMessage.notificationId) } else if (pushMessage.deleteAll) { - cancelAllNotificationsForAccount(context, signatureVerification.user!!) + cancelAllNotificationsForAccount(context, user) } else if (pushMessage.deleteMultiple) { for (notificationId in pushMessage.notificationIds!!) { - cancelNotification(context, signatureVerification.user!!, notificationId) + cancelNotification(context, user, notificationId) } } else if (isTalkNotification()) { Log.d(TAG, "pushMessage.type: " + pushMessage.type) @@ -199,20 +199,20 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val mainActivityIntent = Intent(context, MainActivity::class.java) mainActivityIntent.flags = getIntentFlags() val bundle = Bundle() - bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!) + bundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) bundle.putBoolean(KEY_REMOTE_TALK_SHARE, true) mainActivityIntent.putExtras(bundle) getNcDataAndShowNotification(mainActivityIntent) } private fun handleCallPushMessage() { - val userBeingCalled = userManager.getUserWithId(signatureVerification.user!!.id!!).blockingGet() + val userBeingCalled = userManager.getUserWithId(user.id!!).blockingGet() fun createBundle(conversation: ConversationModel): Bundle { val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, pushMessage.id) bundle.putInt(KEY_NOTIFICATION_TIMESTAMP, pushMessage.timestamp.toInt()) - bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!) + bundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true) val isOneToOneCall = conversation.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL @@ -263,7 +263,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val soundUri = getCallRingtoneUri(applicationContext, appPreferences) val notificationChannelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name - val uri = signatureVerification.user!!.baseUrl!!.toUri() + val uri = user.baseUrl!!.toUri() val baseUrl = uri.host val notification = @@ -287,7 +287,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor sendNotification(pushMessage.timestamp.toInt(), notification) - checkIfCallIsActive(signatureVerification, conversation) + checkIfCallIsActive(conversation) } chatNetworkDataSource?.getRoom(userBeingCalled, roomToken = pushMessage.id!!) @@ -315,10 +315,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } private fun initNcApiAndCredentials() { - credentials = ApiUtils.getCredentials( - signatureVerification.user!!.username, - signatureVerification.user!!.token - )!! + credentials = user.getCredentials() ncApi = retrofit!!.newBuilder().client( okHttpClient!!.newBuilder().cookieJar( JavaNetCookieJar( @@ -332,15 +329,24 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor @Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "ComplexMethod", "LongMethod") private fun initDecryptedData(inputData: Data) { - val subject = inputData.getString(BundleKeys.KEY_NOTIFICATION_SUBJECT) - val signature = inputData.getString(BundleKeys.KEY_NOTIFICATION_SIGNATURE) try { + if (inputData.hasKeyWithValueOfType(BundleKeys.KEY_NOTIFICATION_CLEARTEXT_SUBJECT, String::class.java)) { + val subject = inputData.getString(BundleKeys.KEY_NOTIFICATION_CLEARTEXT_SUBJECT) + val id = inputData.getLong(BundleKeys.KEY_NOTIFICATION_USER_ID, -1) + user = userManager.getUserWithId(id).blockingGet() + pushMessage = LoganSquare.parse(subject, DecryptedPushMessage::class.java) + return + } + + val subject = inputData.getString(BundleKeys.KEY_NOTIFICATION_SUBJECT) + val signature = inputData.getString(BundleKeys.KEY_NOTIFICATION_SIGNATURE) + val base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT) val base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT) val pushUtils = PushUtils() val privateKey = pushUtils.readKeyFromFile(false) as PrivateKey try { - signatureVerification = pushUtils.verifySignature( + val signatureVerification = pushUtils.verifySignature( base64DecodedSignature, base64DecodedSubject ) @@ -353,6 +359,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor String(decryptedSubject), DecryptedPushMessage::class.java ) + user = signatureVerification.user!! } } catch (e: NoSuchAlgorithmException) { Log.e(TAG, "No proper algorithm to decrypt the message ", e) @@ -371,13 +378,11 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor private fun isAdminTalkNotification() = ADMIN_NOTIFICATION_TALK == pushMessage.app private fun getNcDataAndShowNotification(intent: Intent) { - val user = signatureVerification.user - // see https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md ncApi.getNcNotification( credentials, ApiUtils.getUrlForNcNotificationWithId( - user!!.baseUrl!!, + user.baseUrl!!, pushMessage.notificationId.toString() ) ) @@ -496,7 +501,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } val pendingIntent = createUniquePendingIntent(intent) - val uri = signatureVerification.user!!.baseUrl!!.toUri() + val uri = user.baseUrl!!.toUri() val baseUrl = uri.host var contentTitle: CharSequence? = "" @@ -527,7 +532,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val activeStatusBarNotification = findNotificationForRoom( context, - signatureVerification.user!!, + user, pushMessage.id!! ) @@ -574,7 +579,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor .setColor(context!!.resources.getColor(R.color.colorPrimary, null)) val notificationInfoBundle = Bundle() - notificationInfoBundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!) + notificationInfoBundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) // could be an ID or a TOKEN notificationInfoBundle.putString(KEY_ROOM_TOKEN, pushMessage.id) notificationInfoBundle.putLong(KEY_NOTIFICATION_ID, pushMessage.notificationId!!) @@ -599,7 +604,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } notificationBuilder.setContentIntent(pendingIntent) - val groupName = signatureVerification.user!!.id.toString() + "@" + pushMessage.id + val groupName = user.id.toString() + "@" + pushMessage.id notificationBuilder.setGroup(calculateCRC32(groupName).toString()) return notificationBuilder } @@ -657,12 +662,12 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor ) } val person = Person.Builder() - .setKey(signatureVerification.user!!.id.toString() + "@" + notificationUser.id) + .setKey(user.id.toString() + "@" + notificationUser.id) .setName(EmojiCompat.get().process(notificationUser.name!!)) .setBot("bot" == userType) if ("user" == userType || "guest" == userType) { - val baseUrl = signatureVerification.user!!.baseUrl + val baseUrl = user.baseUrl val avatarUrl = if ("user" == userType) { ApiUtils.getUrlForAvatar( baseUrl!!, @@ -683,7 +688,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // NOTE - systemNotificationId is an internal ID used on the device only. // It is NOT the same as the notification ID used in communication with the server. actualIntent.putExtra(KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId) - actualIntent.putExtra(KEY_INTERNAL_USER_ID, signatureVerification.user?.id) + actualIntent.putExtra(KEY_INTERNAL_USER_ID, user.id) actualIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id) actualIntent.putExtra(KEY_MESSAGE_ID, messageId) @@ -872,13 +877,13 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor notificationManager.cancel(notificationId) } - private fun checkIfCallIsActive(signatureVerification: SignatureVerification, conversation: ConversationModel) { + private fun checkIfCallIsActive(conversation: ConversationModel) { Log.d(TAG, "checkIfCallIsActive") var hasParticipantsInCall = true var inCallOnDifferentDevice = false val apiVersion = ApiUtils.getConversationApiVersion( - signatureVerification.user!!, + user, intArrayOf(ApiUtils.API_V4, 1) ) @@ -888,7 +893,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor credentials, ApiUtils.getUrlForCall( apiVersion, - signatureVerification.user!!.baseUrl!!, + user.baseUrl!!, pushMessage.id!! ) ) @@ -906,7 +911,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor hasParticipantsInCall = participantList.isNotEmpty() if (hasParticipantsInCall) { for (participant in participantList) { - if (participant.actorId == signatureVerification.user!!.userId && + if (participant.actorId == user.userId && participant.actorType == Participant.ActorType.USERS ) { inCallOnDifferentDevice = true @@ -1002,7 +1007,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor intent.flags = getIntentFlags() val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, pushMessage.id) - bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!) + bundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) bundle.putBoolean(KEY_OPENED_VIA_NOTIFICATION, true) intent.putExtras(bundle) return intent diff --git a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt index f042ca9359..7b8766980a 100644 --- a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt @@ -11,8 +11,10 @@ import android.util.Log import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import com.nextcloud.talk.jobs.NotificationWorker import com.nextcloud.talk.jobs.PushRegistrationWorker import com.nextcloud.talk.utils.UnifiedPushUtils.toByteArray +import com.nextcloud.talk.utils.bundle.BundleKeys import org.json.JSONException import org.json.JSONObject import org.unifiedpush.android.connector.FailedReason @@ -47,6 +49,14 @@ class UnifiedPushService: PushService() { } catch (_: JSONException) { // Messages are encrypted following RFC8291, and UnifiedPush lib handle the decryption itself: // message.content is the cleartext + val messageData = Data.Builder() + .putLong(BundleKeys.KEY_NOTIFICATION_USER_ID, instance.toLong()) + .putString(BundleKeys.KEY_NOTIFICATION_CLEARTEXT_SUBJECT, message.content.toString(Charsets.UTF_8)) + .build() + val notificationWork = + OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData) + .build() + WorkManager.getInstance(this).enqueue(notificationWork) } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt index d31b7c6e38..cd4135dbdd 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt @@ -30,6 +30,8 @@ object BundleKeys { const val KEY_CALL_URL = "KEY_CALL_URL" const val KEY_NEW_ROOM_NAME = "KEY_NEW_ROOM_NAME" const val KEY_MODIFIED_BASE_URL = "KEY_MODIFIED_BASE_URL" + const val KEY_NOTIFICATION_USER_ID = "KEY_NOTIFICATION_USER_ID" + const val KEY_NOTIFICATION_CLEARTEXT_SUBJECT = "KEY_NOTIFICATION_CLEARTEXT_SUBJECT" const val KEY_NOTIFICATION_SUBJECT = "KEY_NOTIFICATION_SUBJECT" const val KEY_NOTIFICATION_SIGNATURE = "KEY_NOTIFICATION_SIGNATURE" const val KEY_INTERNAL_USER_ID = "KEY_INTERNAL_USER_ID" From d7cc2575082b8c79e0b6a52d23d704ef241d5ef1 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 17:15:55 +0100 Subject: [PATCH 17/45] Allow user to select non-default distributor Signed-off-by: sim --- .../talk/settings/SettingsActivity.kt | 26 ++++++++++++++++- .../nextcloud/talk/utils/UnifiedPushUtils.kt | 6 ++-- app/src/main/res/layout/activity_settings.xml | 28 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index ac9b6412f5..4840cf20a7 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -331,6 +331,7 @@ class SettingsActivity : if (!showUnifiedPushToggle()) { binding.settingsUnifiedpush.visibility = View.GONE } else { + val nDistrib = UnifiedPush.getDistributors(context).size binding.settingsUnifiedpush.visibility = View.VISIBLE binding.settingsUnifiedpushSwitch.isChecked = appPreferences.useUnifiedPush binding.settingsUnifiedpush.setOnClickListener { @@ -338,15 +339,38 @@ class SettingsActivity : appPreferences.useUnifiedPush = checked binding.settingsUnifiedpushSwitch.isChecked = checked setupNotificationPermissionSettings() + setupUnifiedPushServiceSelectionVisibility(nDistrib) if (checked) { UnifiedPushUtils.useDefaultDistributor(this) { distrib -> Log.d(TAG, "Registered to $distrib") - // TODO summary for service change + binding.settingsUnifiedpushServiceSummary.text = distrib } } else { UnifiedPushUtils.disableExternalUnifiedPush(this) } } + // To use non-default service + binding.settingsUnifiedpushService.setOnClickListener { + UnifiedPushUtils.pickDistributor(this) { distrib -> + Log.d(TAG, "Registered to $distrib") + binding.settingsUnifiedpushServiceSummary.text = distrib + } + } + // For the init only + if (binding.settingsUnifiedpushServiceSummary.text.isBlank()) { + binding.settingsUnifiedpushServiceSummary.text = UnifiedPush.getAckDistributor(context) ?: "" + } + setupUnifiedPushServiceSelectionVisibility(nDistrib) + } + } + + private fun setupUnifiedPushServiceSelectionVisibility(nDistrib: Int) { + // We offer the option to use non-default service, only if UnifiedPush + // is enabled and there are more than one service + if (binding.settingsUnifiedpushSwitch.isChecked && nDistrib > 1) { + binding.settingsUnifiedpushService.visibility = View.VISIBLE + } else { + binding.settingsUnifiedpushService.visibility = View.GONE } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index 974561d0cd..23fd5832d2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -59,7 +59,7 @@ object UnifiedPushUtils { * @param accountManager: Used to register all accounts * @param callback: run with the push service name if available */ - /*@JvmStatic + @JvmStatic fun pickDistributor( activity: Activity, callback: (String?) -> Unit @@ -67,13 +67,13 @@ object UnifiedPushUtils { Log.d(TAG, "Picking another UnifiedPush distributor") UnifiedPush.tryPickDistributor(activity as Context) { res -> if (res) { - enqueuePushWorker(activity, true, "useDefaultDistributor") + enqueuePushWorker(activity, true, "pickDistributor") callback(UnifiedPush.getSavedDistributor(activity)) } else { callback(null) } } - }*/ + } /** * Disable UnifiedPush and try to register with proxy push again diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 74e0cc304f..38334e8a82 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -239,6 +239,34 @@ android:clickable="false"/> + + + + + + + + + + Enable UnifiedPush Receive push notifications with an external UnifiedPush service + UnifiedPush Service Screen lock Lock %1$s with Android screen lock or supported biometric method screen_lock From 5ce1038c03d3b453d0929c21484eb53ab3a08834 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 17:43:43 +0100 Subject: [PATCH 18/45] Fix endpoint registration The endpoints are per user, and not general to all users Signed-off-by: sim --- .../nextcloud/talk/jobs/PushRegistrationWorker.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 302a47875d..2c5c7157e4 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -38,7 +38,7 @@ import javax.inject.Inject * Can be used for 4 different things: * - if inputData contains [USER_ID] and [ACTIVATION_TOKEN]: activate web push for user (on server) and unregister * for proxy push (on server) (received from [com.nextcloud.talk.services.UnifiedPushService]) - * - if inputData contains [UNIFIEDPUSH_ENDPOINT]: register for web push (on server) + * - if inputData contains [USER_ID] and [UNIFIEDPUSH_ENDPOINT]: register for web push (on server) * (received from [com.nextcloud.talk.services.UnifiedPushService]) * - if inputData contains [USE_UNIFIEDPUSH] or if [AppPreferences.getUseUnifiedPush]: get the server VAPID key and * register for UnifiedPush to the distributor (on device) @@ -89,9 +89,9 @@ class PushRegistrationWorker( if (userId != -1L && activationToken != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushActivationWork)") webPushActivationWork(userId, activationToken) - } else if (pushEndpoint != null) { + } else if (userId != -1L && pushEndpoint != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushWork)") - webPushWork(pushEndpoint) + webPushWork(userId, pushEndpoint) } else if (useUnifiedPush) { Log.d(TAG, "PushRegistrationWorker called via $origin (unifiedPushWork)") unifiedPushWork() @@ -131,11 +131,9 @@ class PushRegistrationWorker( * Register for web push (on server) */ @SuppressLint("CheckResult") - private fun webPushWork(pushEndpoint: PushEndpoint) { - val obs = userManager.users.blockingGet().map { user -> - registerWebPushForAccount(user, pushEndpoint) - } - Observable.merge(obs) + private fun webPushWork(id: Long, pushEndpoint: PushEndpoint) { + val user = userManager.getUserWithId(id).blockingGet() + registerWebPushForAccount(user, pushEndpoint) .map { (user, res) -> if (res) { Log.d(TAG, "User ${user.userId} registered for web push.") From 681a31b2e5fd4744f80924983b704bd73e4b437f Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 09:22:24 +0100 Subject: [PATCH 19/45] Fix proxy push unregistration Signed-off-by: sim --- .../main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 2c5c7157e4..340a14fe72 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -109,7 +109,7 @@ class PushRegistrationWorker( private fun webPushActivationWork(id: Long, activationToken: String) { val user = userManager.getUserWithId(id).blockingGet() activateWebPushForAccount(user, activationToken) - .map { res -> + .flatMap { res -> if (res) { unregisterProxyPush(user) } else { From bf225a2a9d6c04f49f48637126d4c71d9d420197 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 09:24:32 +0100 Subject: [PATCH 20/45] Log error correctly Signed-off-by: sim --- .../nextcloud/talk/jobs/PushRegistrationWorker.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 340a14fe72..03674f8e39 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -121,8 +121,7 @@ class PushRegistrationWorker( .subscribeOn(Schedulers.io()) .subscribe { _, e -> e?.let { - Log.d(TAG, "An error occurred while activating web push, or unregistering proxy push") - e.printStackTrace() + Log.e(TAG, "An error occurred while activating web push, or unregistering proxy push", e) } } } @@ -144,8 +143,7 @@ class PushRegistrationWorker( .subscribeOn(Schedulers.io()) .subscribe { _, e -> e?.let { - Log.d(TAG, "An error occurred while registering for web push") - e.printStackTrace() + Log.e(TAG, "An error occurred while registering for web push", e) } } } @@ -163,8 +161,7 @@ class PushRegistrationWorker( .subscribeOn(Schedulers.io()) .subscribe { _, e -> e?.let { - Log.d(TAG, "An error occurred while registering for UnifiedPush") - e.printStackTrace() + Log.e(TAG, "An error occurred while registering for UnifiedPush", e) } } } @@ -192,8 +189,7 @@ class PushRegistrationWorker( .subscribeOn(Schedulers.io()) .subscribe { _, e -> e?.let { - Log.d(TAG, "An error occurred while unregistering for web push") - e.printStackTrace() + Log.e(TAG, "An error occurred while unregistering for web push", e) } // Register proxy push for all account, no matter the result of the web push unregistration registerProxyPush() From bf71b0628e22859e9c3a2c03cc9daf9c79c236b5 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 13:02:06 +0100 Subject: [PATCH 21/45] Fix proxy push with multiple account Signed-off-by: sim --- .../main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt | 1 - app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 03674f8e39..a504caa36c 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -31,7 +31,6 @@ import okhttp3.OkHttpClient import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.data.PushEndpoint import retrofit2.Retrofit -import java.net.CookieManager import javax.inject.Inject /** diff --git a/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt index 95e59b2148..bf0d53aeaa 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt @@ -221,6 +221,7 @@ class PushUtils { user: User ) { val credentials = ApiUtils.getCredentials(user.username, user.token) + Log.d(TAG, "Registering proxy push with ${user.userId}'s server.") ncApi.registerDeviceForNotificationsWithNextcloud( credentials, ApiUtils.getUrlNextcloudPush(user.baseUrl!!), From 367a6ab8a5f9b546f21a7d9a0ceae88cb1ae36b0 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 14:49:40 +0100 Subject: [PATCH 22/45] Handle post-push registration in a single place Signed-off-by: sim --- .../nextcloud/talk/account/AccountVerificationActivity.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index f600c227ad..8ed244f24d 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -264,13 +264,7 @@ class AccountVerificationActivity : BaseActivity() { ClosedInterfaceImpl().setUpPushTokenRegistration() } else { Log.w(TAG, "Skipping push registration.") - runOnUiThread { - binding.progressText.text = - """ ${binding.progressText.text} - ${resources!!.getString(R.string.nc_push_disabled)} - """.trimIndent() - } - fetchAndStoreCapabilities() + eventBus.post(EventStatus(user.id!!, EventStatus.EventType.PUSH_REGISTRATION, false)) } } From 335ec818d87cedfb129ddb8346c3fe38f954385d Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:04:08 +0100 Subject: [PATCH 23/45] Use `when` to handle event status Signed-off-by: sim --- .../account/AccountVerificationActivity.kt | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index 8ed244f24d..6d0417e7f6 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -340,41 +340,47 @@ class AccountVerificationActivity : BaseActivity() { @Subscribe(threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(eventStatus: EventStatus) { Log.d(TAG, "caught EventStatus of type " + eventStatus.eventType.toString()) - if (eventStatus.eventType == EventStatus.EventType.PUSH_REGISTRATION) { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { - runOnUiThread { - binding.progressText.text = - """ + // We do PUSH_REGISTRATION -> CAPABILITIES_FETCH -> SIGNALING_SETTINGS + when (eventStatus.eventType) { + EventStatus.EventType.PUSH_REGISTRATION -> { + if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + runOnUiThread { + binding.progressText.text = + """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_push_disabled)} """.trimIndent() + } } + fetchAndStoreCapabilities() } - fetchAndStoreCapabilities() - } else if (eventStatus.eventType == EventStatus.EventType.CAPABILITIES_FETCH) { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { - runOnUiThread { - binding.progressText.text = - """ + EventStatus.EventType.CAPABILITIES_FETCH -> { + if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + runOnUiThread { + binding.progressText.text = + """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_capabilities_failed)} """.trimIndent() + } + abortVerification() + } else if (internalAccountId == eventStatus.userId && eventStatus.isAllGood) { + fetchAndStoreExternalSignalingSettings() } - abortVerification() - } else if (internalAccountId == eventStatus.userId && eventStatus.isAllGood) { - fetchAndStoreExternalSignalingSettings() } - } else if (eventStatus.eventType == EventStatus.EventType.SIGNALING_SETTINGS) { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { - runOnUiThread { - binding.progressText.text = - """ + EventStatus.EventType.SIGNALING_SETTINGS -> { + if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + runOnUiThread { + binding.progressText.text = + """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_external_server_failed)} """.trimIndent() + } } + proceedWithLogin() } - proceedWithLogin() + else -> {} } } From caec07bda00f1d4d9ed347e3fa6b6efd417af1ce Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:10:57 +0100 Subject: [PATCH 24/45] Check once if the event is core internalAccountId Signed-off-by: sim --- .../talk/account/AccountVerificationActivity.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index 6d0417e7f6..b9e248fcd2 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -340,10 +340,14 @@ class AccountVerificationActivity : BaseActivity() { @Subscribe(threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(eventStatus: EventStatus) { Log.d(TAG, "caught EventStatus of type " + eventStatus.eventType.toString()) + if (internalAccountId != eventStatus.userId) { + Log.d(TAG, "Event isn't for us. Aborting.") + return + } // We do PUSH_REGISTRATION -> CAPABILITIES_FETCH -> SIGNALING_SETTINGS when (eventStatus.eventType) { EventStatus.EventType.PUSH_REGISTRATION -> { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ @@ -355,7 +359,7 @@ class AccountVerificationActivity : BaseActivity() { fetchAndStoreCapabilities() } EventStatus.EventType.CAPABILITIES_FETCH -> { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ @@ -364,12 +368,12 @@ class AccountVerificationActivity : BaseActivity() { """.trimIndent() } abortVerification() - } else if (internalAccountId == eventStatus.userId && eventStatus.isAllGood) { + } else { fetchAndStoreExternalSignalingSettings() } } EventStatus.EventType.SIGNALING_SETTINGS -> { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ From 0c8c16f346e2705061931ddaaf5f6322c658d7bf Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:12:45 +0100 Subject: [PATCH 25/45] Handle post-profile storage with the eventbus Signed-off-by: sim --- .../account/AccountVerificationActivity.kt | 20 ++++++++++++------- .../nextcloud/talk/events/EventStatus.java | 3 ++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index b9e248fcd2..ed06ba115e 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -260,12 +260,7 @@ class AccountVerificationActivity : BaseActivity() { @SuppressLint("SetTextI18n") override fun onSuccess(user: User) { internalAccountId = user.id!! - if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { - ClosedInterfaceImpl().setUpPushTokenRegistration() - } else { - Log.w(TAG, "Skipping push registration.") - eventBus.post(EventStatus(user.id!!, EventStatus.EventType.PUSH_REGISTRATION, false)) - } + eventBus.post(EventStatus(user.id!!, EventStatus.EventType.PROFILE_STORED, true)) } @SuppressLint("SetTextI18n") @@ -344,8 +339,19 @@ class AccountVerificationActivity : BaseActivity() { Log.d(TAG, "Event isn't for us. Aborting.") return } - // We do PUSH_REGISTRATION -> CAPABILITIES_FETCH -> SIGNALING_SETTINGS + // We do: PROFILE_STORED + // -> PUSH_REGISTRATION + // -> CAPABILITIES_FETCH + // -> SIGNALING_SETTINGS when (eventStatus.eventType) { + EventStatus.EventType.PROFILE_STORED -> { + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + ClosedInterfaceImpl().setUpPushTokenRegistration() + } else { + Log.w(TAG, "Skipping push registration.") + eventBus.post(EventStatus(eventStatus.userId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } EventStatus.EventType.PUSH_REGISTRATION -> { if (!eventStatus.isAllGood) { runOnUiThread { diff --git a/app/src/main/java/com/nextcloud/talk/events/EventStatus.java b/app/src/main/java/com/nextcloud/talk/events/EventStatus.java index c8470903b9..f8e1af6b16 100644 --- a/app/src/main/java/com/nextcloud/talk/events/EventStatus.java +++ b/app/src/main/java/com/nextcloud/talk/events/EventStatus.java @@ -84,7 +84,8 @@ public String toString() { } public enum EventType { - PUSH_REGISTRATION, CAPABILITIES_FETCH, SIGNALING_SETTINGS, CONVERSATION_UPDATE, PARTICIPANTS_UPDATE + PROFILE_STORED, PUSH_REGISTRATION, CAPABILITIES_FETCH, SIGNALING_SETTINGS, CONVERSATION_UPDATE, + PARTICIPANTS_UPDATE } } From d6f0ac36cdcda46bb97b6c80da68d2bf2cec54f6 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:15:56 +0100 Subject: [PATCH 26/45] Fetch capabilities before registering for Push notifications Signed-off-by: sim --- .../account/AccountVerificationActivity.kt | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index ed06ba115e..bd1ec31ddb 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -340,43 +340,38 @@ class AccountVerificationActivity : BaseActivity() { return } // We do: PROFILE_STORED - // -> PUSH_REGISTRATION // -> CAPABILITIES_FETCH + // -> PUSH_REGISTRATION // -> SIGNALING_SETTINGS when (eventStatus.eventType) { EventStatus.EventType.PROFILE_STORED -> { - if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { - ClosedInterfaceImpl().setUpPushTokenRegistration() - } else { - Log.w(TAG, "Skipping push registration.") - eventBus.post(EventStatus(eventStatus.userId, EventStatus.EventType.PUSH_REGISTRATION, false)) - } + fetchAndStoreCapabilities() } - EventStatus.EventType.PUSH_REGISTRATION -> { + EventStatus.EventType.CAPABILITIES_FETCH -> { if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ ${binding.progressText.text} - ${resources!!.getString(R.string.nc_push_disabled)} + ${resources!!.getString(R.string.nc_capabilities_failed)} """.trimIndent() } + abortVerification() + } else { + setupPushNotifications() } - fetchAndStoreCapabilities() } - EventStatus.EventType.CAPABILITIES_FETCH -> { + EventStatus.EventType.PUSH_REGISTRATION -> { if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ ${binding.progressText.text} - ${resources!!.getString(R.string.nc_capabilities_failed)} + ${resources!!.getString(R.string.nc_push_disabled)} """.trimIndent() } - abortVerification() - } else { - fetchAndStoreExternalSignalingSettings() } + fetchAndStoreExternalSignalingSettings() } EventStatus.EventType.SIGNALING_SETTINGS -> { if (!eventStatus.isAllGood) { @@ -394,6 +389,15 @@ class AccountVerificationActivity : BaseActivity() { } } + private fun setupPushNotifications() { + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + ClosedInterfaceImpl().setUpPushTokenRegistration() + } else { + Log.w(TAG, "Skipping push registration.") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } + private fun fetchAndStoreCapabilities() { val userData = Data.Builder() From ef9b45955c706e6c2006b292027a9007e3cdd141 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:47:28 +0100 Subject: [PATCH 27/45] Register with UnifiedPush when needed during AccountVerification Signed-off-by: sim --- .../account/AccountVerificationActivity.kt | 30 ++++++++++++++++++- .../nextcloud/talk/utils/UnifiedPushUtils.kt | 5 ++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index bd1ec31ddb..8486cc51d6 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -42,6 +42,7 @@ import com.nextcloud.talk.models.json.userprofile.UserProfileOverall import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.UriUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID @@ -58,6 +59,7 @@ import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.unifiedpush.android.connector.UnifiedPush import java.net.CookieManager import javax.inject.Inject @@ -390,8 +392,34 @@ class AccountVerificationActivity : BaseActivity() { } private fun setupPushNotifications() { - if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + // This isn't a first account, and UnifiedPush is enabled. + if (appPreferences.useUnifiedPush) { + if (userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { + UnifiedPushUtils.registerWithCurrentDistributor( + context + ) + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + } else { + Log.w(TAG, "Warning: disabling UnifiedPush, user server doesn't support web push.") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + // This may or may not be a first account, use Play Services if available + } else if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { ClosedInterfaceImpl().setUpPushTokenRegistration() + // This is a first user, we have a UnifiedPush distributor, + // and the server supports web push + } else if (userManager.users.blockingGet().size == 1 && + UnifiedPush.getDistributors(context).isNotEmpty() && + userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { + UnifiedPushUtils.useDefaultDistributor(this) { distrib -> + distrib?.let { + Log.d(TAG, "UnifiedPush registered with $distrib") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + } ?: run { + Log.d(TAG, "No UnifiedPush distrib selected") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } } else { Log.w(TAG, "Skipping push registration.") eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index 23fd5832d2..90ddafe3f2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -49,6 +49,11 @@ object UnifiedPushUtils { } } + @JvmStatic + fun registerWithCurrentDistributor(context: Context) { + enqueuePushWorker(context, true, "registerWithCurrentDistributor") + } + /** * Pick another distributor, register all accounts that support webpush * From 5045f29500925605bc25e8b122277e78f7a2f78a Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 16:28:15 +0100 Subject: [PATCH 28/45] Periodically register for UnifiedPush Signed-off-by: sim --- .../nextcloud/talk/activities/MainActivity.kt | 7 +++- .../nextcloud/talk/utils/UnifiedPushUtils.kt | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index 4c5c6b50dc..d42d82c8bb 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -38,6 +38,7 @@ import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.SecurityUtils +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import io.reactivex.Observer @@ -260,7 +261,11 @@ class MainActivity : override fun onSuccess(users: List) { if (users.isNotEmpty()) { - ClosedInterfaceImpl().setUpPushTokenRegistration() + if (appPreferences.useUnifiedPush) { + UnifiedPushUtils.setPeriodicPushRegistrationWorker(this@MainActivity) + } else { + ClosedInterfaceImpl().setUpPushTokenRegistration() + } runOnUiThread { openConversationList() } diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index 90ddafe3f2..047fa3da70 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -12,15 +12,20 @@ import android.content.Context import android.os.Parcel import android.util.Log import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.OneTimeWorkRequest +import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.PushRegistrationWorker import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.data.PushEndpoint +import java.util.concurrent.TimeUnit object UnifiedPushUtils { private val TAG: String = UnifiedPushUtils::class.java.getSimpleName() + const val DAILY: Long = 24 + const val FLEX_INTERVAL: Long = 10 /** * Use default distributor, register all accounts that support webpush @@ -101,6 +106,33 @@ object UnifiedPushUtils { WorkManager.getInstance(context).enqueue(pushRegistrationWork) } + /** + * Call only if [com.nextcloud.talk.utils.preferences.AppPreferences.getUseUnifiedPush], + * else [ClosedInterfaceImpl.setUpPushTokenRegistration] is called and does the same as + * this function + */ + fun setPeriodicPushRegistrationWorker(context: Context) { + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushUtils#setPeriodicPushRegistrationWorker") + .build() + val periodicPushRegistrationWork = PeriodicWorkRequest.Builder( + PushRegistrationWorker::class.java, + DAILY, + TimeUnit.HOURS, + FLEX_INTERVAL, + TimeUnit.HOURS + ) + .setInputData(data) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + "periodicPushRegistrationWorker", + ExistingPeriodicWorkPolicy.UPDATE, + periodicPushRegistrationWork + ) + } + /** * Get UnifiedPush instance for user * From a0b408abfafab1e3f770a0f169e18895c1e281f4 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 17:36:32 +0100 Subject: [PATCH 29/45] Fix disable UnifiedPush when adding new UP account without web push Signed-off-by: sim --- .../com/nextcloud/talk/account/AccountVerificationActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index 8486cc51d6..bc2e7b0788 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -402,6 +402,7 @@ class AccountVerificationActivity : BaseActivity() { } else { Log.w(TAG, "Warning: disabling UnifiedPush, user server doesn't support web push.") eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + appPreferences.useUnifiedPush = false } // This may or may not be a first account, use Play Services if available } else if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { From 2456da747d0c96efc64fa5d9a00341159f2b52da Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 07:49:02 +0100 Subject: [PATCH 30/45] Fix push notification registration for new account without webpush when UP is enabled Signed-off-by: sim --- .../talk/account/AccountVerificationActivity.kt | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index bc2e7b0788..7f9f7c1bd2 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -395,20 +395,21 @@ class AccountVerificationActivity : BaseActivity() { // This isn't a first account, and UnifiedPush is enabled. if (appPreferences.useUnifiedPush) { if (userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { - UnifiedPushUtils.registerWithCurrentDistributor( - context - ) + UnifiedPushUtils.registerWithCurrentDistributor(context) eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + return } else { Log.w(TAG, "Warning: disabling UnifiedPush, user server doesn't support web push.") - eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) appPreferences.useUnifiedPush = false } - // This may or may not be a first account, use Play Services if available - } else if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + } + + // - By default, use the Play Services if available + // - If this is a first user, and we have an External UnifiedPush distributor, + // and the server supports it: we use it + // - Else we skip push registrations + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { ClosedInterfaceImpl().setUpPushTokenRegistration() - // This is a first user, we have a UnifiedPush distributor, - // and the server supports web push } else if (userManager.users.blockingGet().size == 1 && UnifiedPush.getDistributors(context).isNotEmpty() && userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { From ad58e9a3e789c445bc1df5671d58483719d85338 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:14:54 +0100 Subject: [PATCH 31/45] Fix useUnifiedPush with first user verification Signed-off-by: sim --- .../com/nextcloud/talk/account/AccountVerificationActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index 7f9f7c1bd2..5ce1302925 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -416,6 +416,7 @@ class AccountVerificationActivity : BaseActivity() { UnifiedPushUtils.useDefaultDistributor(this) { distrib -> distrib?.let { Log.d(TAG, "UnifiedPush registered with $distrib") + appPreferences.useUnifiedPush = true eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) } ?: run { Log.d(TAG, "No UnifiedPush distrib selected") From ce8130120c1e3a26dfb7539b6fd126435f279d42 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:29:06 +0100 Subject: [PATCH 32/45] Do not show UnifiedPush Service settings when UP isn't shown Signed-off-by: sim --- .../main/java/com/nextcloud/talk/settings/SettingsActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 4840cf20a7..c6b0386f8f 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -330,6 +330,7 @@ class SettingsActivity : // we require that all the users support webpush if (!showUnifiedPushToggle()) { binding.settingsUnifiedpush.visibility = View.GONE + binding.settingsUnifiedpushService.visibility = View.GONE } else { val nDistrib = UnifiedPush.getDistributors(context).size binding.settingsUnifiedpush.visibility = View.VISIBLE From 75113e187daf7190a6c27abb3559d110b272f1bd Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:49:00 +0100 Subject: [PATCH 33/45] Request notif permission with UnifiedPush Signed-off-by: sim --- .../talk/conversationlist/ConversationsListActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 402b266a0b..fd37383080 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -282,7 +282,8 @@ class ConversationsListActivity : // handle notification permission on API level >= 33 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !platformPermissionUtil.isPostNotificationsPermissionGranted() && - ClosedInterfaceImpl().isGooglePlayServicesAvailable + (ClosedInterfaceImpl().isGooglePlayServicesAvailable || + appPreferences.useUnifiedPush) ) { requestPermissions( arrayOf(Manifest.permission.POST_NOTIFICATIONS), From 37123a500e64feadbdb94485e9ea89740f7eef11 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 09:15:59 +0100 Subject: [PATCH 34/45] Show latest endpoint reception in diagnose Signed-off-by: sim --- .../nextcloud/talk/diagnose/DiagnoseActivity.kt | 14 ++++++++++++++ .../nextcloud/talk/jobs/PushRegistrationWorker.kt | 1 + .../talk/utils/preferences/AppPreferences.java | 4 ++++ .../talk/utils/preferences/AppPreferencesImpl.kt | 13 +++++++++++++ app/src/main/res/values/strings.xml | 1 + 5 files changed, 33 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt index a9edee6b4d..5b2357fb35 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt @@ -359,6 +359,20 @@ class DiagnoseActivity : BaseActivity() { key = getString(R.string.nc_diagnose_unifiedpush_service), value = unifiedPushService ) + + addDiagnosisEntry( + key = context.resources.getString(R.string.nc_diagnose_unifiedpush_latest_endpoint), + value = if (appPreferences.unifiedPushLatestEndpoint != null && + appPreferences.unifiedPushLatestEndpoint != 0L + ) { + DisplayUtils.unixTimeToHumanReadable( + appPreferences + .unifiedPushLatestEndpoint + ) + } else { + context.resources.getString(R.string.nc_common_unknown) + } + ) } @Suppress("Detekt.LongMethod") diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index a504caa36c..47d91a5c42 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -130,6 +130,7 @@ class PushRegistrationWorker( */ @SuppressLint("CheckResult") private fun webPushWork(id: Long, pushEndpoint: PushEndpoint) { + preferences.unifiedPushLatestEndpoint = System.currentTimeMillis() val user = userManager.getUserWithId(id).blockingGet() registerWebPushForAccount(user, pushEndpoint) .map { (user, res) -> diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index bea129d000..32d702ba03 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -72,6 +72,10 @@ public interface AppPreferences { void setUseUnifiedPush(boolean value); + Long getUnifiedPushLatestEndpoint(); + + void setUnifiedPushLatestEndpoint(Long date); + String getTemporaryClientCertAlias(); void setTemporaryClientCertAlias(String alias); diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index e2879835d0..2739cd8038 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -155,6 +155,18 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { } } + override fun getUnifiedPushLatestEndpoint(): Long = + runBlocking { + async { readLong(UNIFIEDPUSH_LATEST_ENDPOINT).first() } + }.getCompleted() + + override fun setUnifiedPushLatestEndpoint(date: Long) = + runBlocking { + async { + writeLong(UNIFIEDPUSH_LATEST_ENDPOINT, date) + } + } + override fun getPushTokenLatestGeneration(): Long = runBlocking { async { readLong(PUSH_TOKEN_LATEST_GENERATION).first() } @@ -612,6 +624,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val PUSH_TOKEN_LATEST_GENERATION = "push_token_latest_generation" const val PUSH_TOKEN_LATEST_FETCH = "push_token_latest_fetch" const val USE_UNIFIEDPUSH = "use_unifiedpush" + const val UNIFIEDPUSH_LATEST_ENDPOINT = "unifiedpush_latest_endpoint" const val TEMP_CLIENT_CERT_ALIAS = "tempClientCertAlias" const val CALL_RINGTONE = "call_ringtone" const val MESSAGE_RINGTONE = "message_ringtone" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 427f6fa290..f2c114d27d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -215,6 +215,7 @@ How to translate with transifex: Offer UnifiedPush Use UnifiedPush UnifiedPush service + Latest endpoint received Server supports webpush? Google Play services are not available. Notifications are not supported UnifiedPush is disabled and Google Play services are not available. Notifications are not supported From 571dddb84a43adb5439f90215ca3591f6e016bb6 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 15:57:35 +0100 Subject: [PATCH 35/45] Change log for registration failure Signed-off-by: sim --- .../java/com/nextcloud/talk/services/UnifiedPushService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt index 7b8766980a..30a9219c94 100644 --- a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt @@ -61,7 +61,8 @@ class UnifiedPushService: PushService() { } override fun onRegistrationFailed(reason: FailedReason, instance: String) { - Log.d(TAG, "Registration failed for $instance") + Log.w(TAG, "Registration failed for $instance, reason=$reason") + // Do nothing, we let the periodic worker try to re-register later } override fun onUnregistered(instance: String) { From 62f4998d87877bb85ce6655e862947966974e6db Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 19:15:18 +0100 Subject: [PATCH 36/45] Unregister web push from distrib Signed-off-by: sim --- .../talk/jobs/PushRegistrationWorker.kt | 63 +++++++++++++++++++ .../talk/services/UnifiedPushService.kt | 9 +++ 2 files changed, 72 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 47d91a5c42..f3a348c8ad 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -11,6 +11,9 @@ package com.nextcloud.talk.jobs import android.annotation.SuppressLint import android.content.Context import android.util.Log +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters import autodagger.AutoInjector @@ -23,9 +26,11 @@ import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.PushUtils import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.UnifiedPushUtils.toPushEndpoint +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observable import io.reactivex.schedulers.Schedulers +import kotlinx.serialization.json.Json import okhttp3.CookieJar import okhttp3.OkHttpClient import org.unifiedpush.android.connector.UnifiedPush @@ -84,6 +89,7 @@ class PushRegistrationWorker( val userId = inputData.getLong(USER_ID, -1) val activationToken = inputData.getString(ACTIVATION_TOKEN) val pushEndpoint = inputData.getByteArray(UNIFIEDPUSH_ENDPOINT)?.toPushEndpoint() + val unregisterWebPush = inputData.getBoolean(UNREGISTER_WEBPUSH, false) val useUnifiedPush = inputData.getBoolean(USE_UNIFIEDPUSH, defaultUseUnifiedPush()) if (userId != -1L && activationToken != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushActivationWork)") @@ -91,6 +97,9 @@ class PushRegistrationWorker( } else if (userId != -1L && pushEndpoint != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushWork)") webPushWork(userId, pushEndpoint) + } else if (userId != -1L && unregisterWebPush) { + Log.d(TAG, "PushRegistrationWorker called via $origin (webPushUnregistrationWork)") + webPushUnregistrationWork(userId) } else if (useUnifiedPush) { Log.d(TAG, "PushRegistrationWorker called via $origin (unifiedPushWork)") unifiedPushWork() @@ -148,6 +157,27 @@ class PushRegistrationWorker( } } + /** + * Unregister web push for user + * + * Disable UnifiedPush if we don't have a distributor anymore + */ + @SuppressLint("CheckResult") + private fun webPushUnregistrationWork(id: Long) { + userManager.getUserWithId(id).map { user -> + unregisterWebPushForAccount(user) + .toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.e(TAG, "An error occurred while unregistering for web push", e) + } ?: { + Log.d(TAG, "${user.userId} unregistered from web push") + } + } + } + } + /** * Get VAPID key (on server) and register UnifiedPush to the distributor (on device) */ @@ -205,9 +235,30 @@ class PushRegistrationWorker( if (it == null) { Log.d(TAG, "No saved distributor found: disabling UnifiedPush") preferences.useUnifiedPush = false + if (inputData.keyValueMap.any { (key, _) -> + RESTART_ON_DISTRIB_UNINSTALL.contains(key) + }) { + enqueueWorkerWithoutData("defaultUseDistributor") + } } } != null + /** + * Run the default worker, to use FCM if available + * when the distributor has been uninstalled + */ + private fun enqueueWorkerWithoutData(origin: String) { + // Run the default worker, to use FCM if available + val data = Data.Builder() + .putString(ORIGIN, "PushRegistrationWorker#$origin") + .build() + val periodicPushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(applicationContext) + .enqueue(periodicPushRegistrationWork) + } + /** * Register proxy push for all accounts if the devices support the Play Services * @@ -384,5 +435,17 @@ class PushRegistrationWorker( const val ACTIVATION_TOKEN = "activation_token" const val USE_UNIFIEDPUSH = "use_unifiedpush" const val UNIFIEDPUSH_ENDPOINT = "unifiedpush_endpoint" + const val UNREGISTER_WEBPUSH = "unregister_webpush" + + /** + * If any of these actions are present when we observe the distributor is uninstalled, + * we enqueue a worker with default settings, to fallback to FCM if needed + */ + private val RESTART_ON_DISTRIB_UNINSTALL = listOf( + ACTIVATION_TOKEN, + USE_UNIFIEDPUSH, + UNIFIEDPUSH_ENDPOINT, + UNREGISTER_WEBPUSH + ) } } diff --git a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt index 30a9219c94..b21829b2b7 100644 --- a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt @@ -67,6 +67,15 @@ class UnifiedPushService: PushService() { override fun onUnregistered(instance: String) { Log.d(TAG, "$instance unregistered") + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushService#onUnregistered") + .putBoolean(PushRegistrationWorker.UNREGISTER_WEBPUSH, true) + .putLong(PushRegistrationWorker.USER_ID, instance.toLong()) + .build() + val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(this).enqueue(pushRegistrationWork) } private fun onActivationToken(activationToken: String, instance: String) { From 78ad01838ad8494711d4b7d02f1659cfc63cf8e7 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 19:16:01 +0100 Subject: [PATCH 37/45] Show notif when UnifiedPush distrib is removed Signed-off-by: sim --- .../nextcloud/talk/jobs/NotificationWorker.kt | 20 ++++++++++------ .../talk/jobs/PushRegistrationWorker.kt | 24 +++++++++++++++++++ .../models/json/push/DecryptedPushMessage.kt | 6 ++++- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt index 2f4b93aa37..9f921c77da 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -174,9 +174,11 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } else if (isAdminTalkNotification()) { Log.d(TAG, "pushMessage.type: " + pushMessage.type) when (pushMessage.type) { - TYPE_ADMIN_NOTIFICATIONS -> handleTestPushMessage() + TYPE_ADMIN_NOTIFICATIONS -> handleInternalPushMessage() else -> Log.e(TAG, pushMessage.type + " is not handled") } + } else if (isInternal()) { + handleInternalPushMessage() } else { Log.d(TAG, "a pushMessage that is not for spreed was received.") } @@ -184,7 +186,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor return Result.success() } - private fun handleTestPushMessage() { + private fun handleInternalPushMessage() { val intent = Intent(context, MainActivity::class.java) intent.flags = getIntentFlags() showNotification(intent, null) @@ -374,6 +376,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } private fun isTalkNotification() = SPREED_APP == pushMessage.app + private fun isInternal() = INTERNAL == pushMessage.app private fun isAdminTalkNotification() = ADMIN_NOTIFICATION_TALK == pushMessage.app @@ -530,11 +533,13 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor notificationBuilder.setLargeIcon(getLargeIcon()) } - val activeStatusBarNotification = findNotificationForRoom( - context, - user, - pushMessage.id!! - ) + val activeStatusBarNotification = pushMessage.id?.let { + findNotificationForRoom( + context, + user, + it + ) + } // NOTE - systemNotificationId is an internal ID used on the device only. // It is NOT the same as the notification ID used in communication with the server. @@ -1037,6 +1042,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor private const val TYPE_REMINDER = "reminder" private const val TYPE_ADMIN_NOTIFICATIONS = "admin_notifications" private const val SPREED_APP = "spreed" + private const val INTERNAL = "internal" private const val ADMIN_NOTIFICATION_TALK = "admin_notification_talk" private const val TIMER_START = 1 private const val TIMER_COUNT = 12 diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index f3a348c8ad..29df873fb5 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -239,6 +239,7 @@ class PushRegistrationWorker( RESTART_ON_DISTRIB_UNINSTALL.contains(key) }) { enqueueWorkerWithoutData("defaultUseDistributor") + enqueueNotifUnifiedPushDisabled() } } } != null @@ -259,6 +260,29 @@ class PushRegistrationWorker( .enqueue(periodicPushRegistrationWork) } + /** + * Show a notification to the user to inform UnifiedPush has been disabled + */ + @SuppressLint("CheckResult") + private fun enqueueNotifUnifiedPushDisabled() { + val user = userManager.users.blockingGet().first() + Log.d(TAG, "Sending warning notification with ${user.userId}") + val notif = hashMapOf( + "subject" to "UnifiedPush disabled", + "text" to "You have been unregistered from the distributor. Re-enable in the settings if needed", + "app" to "internal", + "type" to "admin_notifications" + ) + val messageData = Data.Builder() + .putLong(BundleKeys.KEY_NOTIFICATION_USER_ID, user.id!!) + .putString(BundleKeys.KEY_NOTIFICATION_CLEARTEXT_SUBJECT, Json.encodeToString(notif)) + .build() + val notificationWork = + OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData) + .build() + WorkManager.getInstance(applicationContext).enqueue(notificationWork) + } + /** * Register proxy push for all accounts if the devices support the Play Services * diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt index e3bb3cc74e..6b519de312 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt @@ -46,7 +46,11 @@ data class DecryptedPushMessage( @JsonIgnore var notificationUser: NotificationUser?, - @JsonIgnore + /** + * /!\ It is overridden by common NC notifications, just used + * for internal notifications + */ + @JsonField(name = ["text"]) var text: String?, @JsonIgnore From 0f1d119d9aa3062f5d71e0a7f9f72dc758fb2584 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 19:17:13 +0100 Subject: [PATCH 38/45] Add comment to explain why we disable UnifiedPush Signed-off-by: sim --- .../main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 29df873fb5..ebcb531afc 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -232,6 +232,8 @@ class PushRegistrationWorker( // => we can't be acked by the distributor yet, [UnifiedPush.getAckDistributor] == null // So we check the SavedDistributor instead UnifiedPush.getSavedDistributor(applicationContext).also { + // It is null if the distributor has unregistered all the accounts, + // or if it has been uninstalled from the system if (it == null) { Log.d(TAG, "No saved distributor found: disabling UnifiedPush") preferences.useUnifiedPush = false From 6ba5c1b649b61e288a0bc5ed7d71d67cc2550264 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 21:20:43 +0100 Subject: [PATCH 39/45] feat(unifiedpush): May show an introduction dialog if the user has multiple distrbutor on first run Signed-off-by: sim --- .../account/AccountVerificationActivity.kt | 54 ++++++++++++++---- .../ui/dialog/IntroduceUnifiedPushDialog.kt | 56 +++++++++++++++++++ .../nextcloud/talk/utils/UnifiedPushUtils.kt | 11 +++- .../layout/activity_account_verification.xml | 5 ++ app/src/main/res/values/strings.xml | 2 + 5 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index 5ce1302925..d81d6b2ea5 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -39,6 +39,7 @@ import com.nextcloud.talk.jobs.WebsocketConnectionsWorker import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall import com.nextcloud.talk.models.json.generic.Status import com.nextcloud.talk.models.json.userprofile.UserProfileOverall +import com.nextcloud.talk.ui.dialog.IntroduceUnifiedPushDialog import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -410,25 +411,58 @@ class AccountVerificationActivity : BaseActivity() { // - Else we skip push registrations if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { ClosedInterfaceImpl().setUpPushTokenRegistration() + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) } else if (userManager.users.blockingGet().size == 1 && UnifiedPush.getDistributors(context).isNotEmpty() && userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { - UnifiedPushUtils.useDefaultDistributor(this) { distrib -> - distrib?.let { - Log.d(TAG, "UnifiedPush registered with $distrib") - appPreferences.useUnifiedPush = true - eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) - } ?: run { - Log.d(TAG, "No UnifiedPush distrib selected") - eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) - } - } + useUnifiedPushIntroduced() } else { Log.w(TAG, "Skipping push registration.") eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) } } + /** + * Show a dialog if the user has to select their distributor + * + * Most of the time, nothing will be shown, as most users have + * a single distributor, or already selected their default one + */ + private fun useUnifiedPushIntroduced() { + if (UnifiedPushUtils.usingDefaultDistributorNeedsIntro(context)) { + dialogForUnifiedPush { res -> + if (res) { + useUnifiedPush() + } + } + } else { + useUnifiedPush() + } + } + + private fun useUnifiedPush() { + UnifiedPushUtils.useDefaultDistributor(this) { distrib -> + distrib?.let { + Log.d(TAG, "UnifiedPush registered with $distrib") + appPreferences.useUnifiedPush = true + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + } ?: run { + Log.d(TAG, "No UnifiedPush distrib selected") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } + } + + private fun dialogForUnifiedPush(onResponse: (Boolean) -> Unit) { + binding.genericComposeView.apply { + setContent { + IntroduceUnifiedPushDialog { res -> + onResponse(res) + } + } + } + } + private fun fetchAndStoreCapabilities() { val userData = Data.Builder() diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt new file mode 100644 index 0000000000..25648eb508 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.dialog; + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable; +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.nextcloud.talk.R + +@Composable +fun IntroduceUnifiedPushDialog( + onResponse: (Boolean) -> Unit +) { + var showDialog by remember { mutableStateOf(true) } + if (showDialog) { + AlertDialog( + confirmButton = { + TextButton(onClick = { + onResponse(true) + showDialog = false + }) { + Text(stringResource(android.R.string.ok)) + } + }, + onDismissRequest = { + onResponse(false) + showDialog = false + }, + dismissButton = { + TextButton(onClick = { + onResponse(false) + showDialog = false + }) { + Text(stringResource(android.R.string.cancel)) + } + }, + title = { + Text(stringResource(R.string.unifiedpush)) + }, + text = { + Text(stringResource(R.string.nc_dialog_introduce_unifiedpush_selection)) + }, + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index 047fa3da70..f43211b0b8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -20,6 +20,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.PushRegistrationWorker import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.ResolvedDistributor import java.util.concurrent.TimeUnit object UnifiedPushUtils { @@ -44,7 +45,7 @@ object UnifiedPushUtils { callback: (String?) -> Unit ) { Log.d(TAG, "Using default UnifiedPush distributor") - UnifiedPush.tryUseCurrentOrDefaultDistributor(activity as Context) { res -> + UnifiedPush.tryUseDefaultDistributor(activity) { res -> if (res) { enqueuePushWorker(activity, true, "useDefaultDistributor") callback(UnifiedPush.getSavedDistributor(activity)) @@ -54,6 +55,14 @@ object UnifiedPushUtils { } } + /** + * Does [useDefaultDistributor] show an OS screen to ask the user + * to pick a distributor ? + */ + @JvmStatic + fun usingDefaultDistributorNeedsIntro(context: Context): Boolean = + UnifiedPush.resolveDefaultDistributor(context) == ResolvedDistributor.ToSelect + @JvmStatic fun registerWithCurrentDistributor(context: Context) { enqueuePushWorker(context, true, "registerWithCurrentDistributor") diff --git a/app/src/main/res/layout/activity_account_verification.xml b/app/src/main/res/layout/activity_account_verification.xml index 4e64db5ed1..8e3808b927 100644 --- a/app/src/main/res/layout/activity_account_verification.xml +++ b/app/src/main/res/layout/activity_account_verification.xml @@ -43,4 +43,9 @@ android:textColor="@color/fg_default" tools:text="Verifying..." /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2c114d27d..12031168b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,6 +71,8 @@ How to translate with transifex: Could not store display name, aborting Sorry something went wrong, error is %1$s Sorry something went wrong, cannot fetch test push message + You are about to select your default push service + UnifiedPush Search Clear search From 657f59cb7e178e8b6b3a7432a7a804e6d0d25363 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 21:25:44 +0100 Subject: [PATCH 40/45] Fix add comment for notif on unregister Signed-off-by: sim --- .../main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index ebcb531afc..c8ab1fcd51 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -90,6 +90,8 @@ class PushRegistrationWorker( val activationToken = inputData.getString(ACTIVATION_TOKEN) val pushEndpoint = inputData.getByteArray(UNIFIEDPUSH_ENDPOINT)?.toPushEndpoint() val unregisterWebPush = inputData.getBoolean(UNREGISTER_WEBPUSH, false) + // We always check current status of unifiedpush with defaultUseUnifiedPush here + // If the current distributor is removed, a notification to inform the user is shown val useUnifiedPush = inputData.getBoolean(USE_UNIFIEDPUSH, defaultUseUnifiedPush()) if (userId != -1L && activationToken != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushActivationWork)") From d3b56cb64a9d973d55d412211f16d70bb13fef46 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 21:27:45 +0100 Subject: [PATCH 41/45] feat(unifiedpush): Unregister from Distributor when disabling UP Signed-off-by: sim --- app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index f43211b0b8..f84f457e63 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -101,6 +101,7 @@ object UnifiedPushUtils { fun disableExternalUnifiedPush( context: Context ) { + UnifiedPush.unregister(context) enqueuePushWorker(context, false, "disableExternalUnifiedPush") } From 2a01a6580c40c85e8ed29937ab14b64408abdc67 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 21:38:10 +0100 Subject: [PATCH 42/45] feat(unifiedpush): Add user.id to logs during web push registration Signed-off-by: sim --- .../main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index c8ab1fcd51..20a74cb947 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -430,7 +430,7 @@ class PushRegistrationWorker( user: User ): Observable> { if (user.hasWebPushCapability) { - Log.d(TAG, "Registering UnifiedPush for ${user.userId}") + Log.d(TAG, "Registering UnifiedPush for ${user.userId} (${user.id})") if (user.userId == null || user.baseUrl == null) { Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") return Observable.empty() From 504071416b74994b3781bb5145cc2294101d69be Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:02:05 +0100 Subject: [PATCH 43/45] Add embedded distributor to use Play Services with the generic flavor Signed-off-by: sim --- app/build.gradle | 1 + .../account/AccountVerificationActivity.kt | 29 +++++++++++++++++-- .../talk/diagnose/DiagnoseActivity.kt | 3 +- .../talk/jobs/PushRegistrationWorker.kt | 4 +++ .../talk/settings/SettingsActivity.kt | 4 +-- .../nextcloud/talk/utils/UnifiedPushUtils.kt | 20 +++++++++++++ gradle/verification-metadata.xml | 5 +++- 7 files changed, 60 insertions(+), 6 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 160efc7e3d..ba3e678e58 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -331,6 +331,7 @@ dependencies { gplayImplementation "com.google.firebase:firebase-messaging:25.0.1" implementation 'org.unifiedpush.android:connector:3.3.1' + genericImplementation 'org.unifiedpush.android:embedded-fcm-distributor:3.1.0-rc1' //compose implementation(platform("androidx.compose:compose-bom:2026.02.00")) diff --git a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt index d81d6b2ea5..4829535601 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -408,14 +408,19 @@ class AccountVerificationActivity : BaseActivity() { // - By default, use the Play Services if available // - If this is a first user, and we have an External UnifiedPush distributor, // and the server supports it: we use it + // - Else if there is an embedded distributor (so this is a generic flavor, and the + // Play services are installed) => we use it for all accounts that support web push // - Else we skip push registrations if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { ClosedInterfaceImpl().setUpPushTokenRegistration() eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) } else if (userManager.users.blockingGet().size == 1 && - UnifiedPush.getDistributors(context).isNotEmpty() && + UnifiedPushUtils.getExternalDistributors(context).isNotEmpty() && userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { useUnifiedPushIntroduced() + } else if (UnifiedPushUtils.hasEmbeddedDistributor(context) && + userManager.users.blockingGet().any { it.hasWebPushCapability }) { + useEmbeddedUnifiedPush() } else { Log.w(TAG, "Skipping push registration.") eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) @@ -433,6 +438,8 @@ class AccountVerificationActivity : BaseActivity() { dialogForUnifiedPush { res -> if (res) { useUnifiedPush() + } else { + fallbackToEmbeddedUnifiedPush() } } } else { @@ -440,6 +447,24 @@ class AccountVerificationActivity : BaseActivity() { } } + /** + * Check if there is an embedded distributor, and use it if present, + * else, send EventStatus PUSH_REGISTRATION with success=false + */ + private fun fallbackToEmbeddedUnifiedPush() { + if (UnifiedPushUtils.hasEmbeddedDistributor(context)) { + useEmbeddedUnifiedPush() + } else { + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } + + private fun useEmbeddedUnifiedPush() { + UnifiedPushUtils.useEmbeddedDistributor(context) + UnifiedPushUtils.registerWithCurrentDistributor(context) + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + } + private fun useUnifiedPush() { UnifiedPushUtils.useDefaultDistributor(this) { distrib -> distrib?.let { @@ -448,7 +473,7 @@ class AccountVerificationActivity : BaseActivity() { eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) } ?: run { Log.d(TAG, "No UnifiedPush distrib selected") - eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + fallbackToEmbeddedUnifiedPush() } } } diff --git a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt index 5b2357fb35..08571d0f2e 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt @@ -50,6 +50,7 @@ import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.PushUtils.Companion.LATEST_PUSH_REGISTRATION_AT_PUSH_PROXY import com.nextcloud.talk.utils.PushUtils.Companion.LATEST_PUSH_REGISTRATION_AT_SERVER +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils @@ -103,7 +104,7 @@ class DiagnoseActivity : BaseActivity() { val colorScheme = viewThemeUtils.getColorScheme(this) isGooglePlayServicesAvailable = ClosedInterfaceImpl().isGooglePlayServicesAvailable - nUnifiedPushServices = UnifiedPush.getDistributors(this).size + nUnifiedPushServices = UnifiedPushUtils.getExternalDistributors(this).size offerUnifiedPush = nUnifiedPushServices > 0 && userManager.users.blockingGet().all { it.hasWebPushCapability } useUnifiedPush = appPreferences.useUnifiedPush diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 20a74cb947..d8e1092503 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -105,6 +105,10 @@ class PushRegistrationWorker( } else if (useUnifiedPush) { Log.d(TAG, "PushRegistrationWorker called via $origin (unifiedPushWork)") unifiedPushWork() + } else if (UnifiedPushUtils.hasEmbeddedDistributor(applicationContext)) { + Log.d(TAG, "PushRegistrationWorker called via $origin (unifiedPushWork#embeddedDistrib)") + UnifiedPushUtils.useEmbeddedDistributor(applicationContext) + unifiedPushWork() } else { Log.d(TAG, "PushRegistrationWorker called via $origin (proxyPushWork)") proxyPushWork() diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index c6b0386f8f..1c4a54636b 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -317,7 +317,7 @@ class SettingsActivity : } private fun showUnifiedPushToggle(): Boolean { - return UnifiedPush.getDistributors(this).isNotEmpty() && + return UnifiedPushUtils.getExternalDistributors(this).isNotEmpty() && userManager.users.blockingGet().all { it.hasWebPushCapability } } @@ -332,7 +332,7 @@ class SettingsActivity : binding.settingsUnifiedpush.visibility = View.GONE binding.settingsUnifiedpushService.visibility = View.GONE } else { - val nDistrib = UnifiedPush.getDistributors(context).size + val nDistrib = UnifiedPushUtils.getExternalDistributors(context).size binding.settingsUnifiedpush.visibility = View.VISIBLE binding.settingsUnifiedpushSwitch.isChecked = appPreferences.useUnifiedPush binding.settingsUnifiedpush.setOnClickListener { diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index f84f457e63..d7ebdd031a 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -105,6 +105,26 @@ object UnifiedPushUtils { enqueuePushWorker(context, false, "disableExternalUnifiedPush") } + /** + * Check if we have a FCM embedded distributor, to get push notifications, + * using the Play services, using Web Push + * + * Available on the generic flavor only + */ + @JvmStatic + fun hasEmbeddedDistributor(context: Context) = + context.packageName in UnifiedPush.getDistributors(context) + + @JvmStatic + fun useEmbeddedDistributor(context: Context) = + UnifiedPush.saveDistributor(context, context.packageName) + + @JvmStatic + fun getExternalDistributors(context: Context) = + UnifiedPush.getDistributors(context).filter { + it != context.packageName + } + private fun enqueuePushWorker(context: Context, useUnifiedPush: Boolean, origin: String) { val data = Data.Builder() .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushUtils#$origin") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 40ec208d0b..9783345249 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -236,7 +236,10 @@ - + + + + From 6bae963f09dfe68b1f6162a191dc5a3023b59a91 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:49:44 +0100 Subject: [PATCH 44/45] Request notif permission with embedded distrib Signed-off-by: sim --- .../talk/conversationlist/ConversationsListActivity.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index fd37383080..c659a5c8c2 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -128,6 +128,7 @@ import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.SpreedFeatures +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.ADD_ADDITIONAL_ACCOUNT @@ -283,7 +284,8 @@ class ConversationsListActivity : if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !platformPermissionUtil.isPostNotificationsPermissionGranted() && (ClosedInterfaceImpl().isGooglePlayServicesAvailable || - appPreferences.useUnifiedPush) + appPreferences.useUnifiedPush || + UnifiedPushUtils.hasEmbeddedDistributor(context)) ) { requestPermissions( arrayOf(Manifest.permission.POST_NOTIFICATIONS), From 74f54521cfd7592110a6c14d68fa2cd2ee943dfc Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:56:32 +0100 Subject: [PATCH 45/45] Fix settings & diagnose with embedded distrib Signed-off-by: sim --- .../com/nextcloud/talk/diagnose/DiagnoseActivity.kt | 10 +++++++--- .../com/nextcloud/talk/settings/SettingsActivity.kt | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt index 08571d0f2e..ae44be5509 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt @@ -80,6 +80,7 @@ class DiagnoseActivity : BaseActivity() { lateinit var platformPermissionUtil: PlatformPermissionUtil private var isGooglePlayServicesAvailable: Boolean = false + private var useEmbeddedDistrib: Boolean = false private var nUnifiedPushServices = 0 private var offerUnifiedPush: Boolean = false @@ -108,6 +109,7 @@ class DiagnoseActivity : BaseActivity() { offerUnifiedPush = nUnifiedPushServices > 0 && userManager.users.blockingGet().all { it.hasWebPushCapability } useUnifiedPush = appPreferences.useUnifiedPush + useEmbeddedDistrib = UnifiedPushUtils.hasEmbeddedDistributor(context) && !useUnifiedPush unifiedPushService = UnifiedPush.getAckDistributor(this) ?: "N/A" setContent { @@ -161,7 +163,9 @@ class DiagnoseActivity : BaseActivity() { viewState = viewState, onTestPushClick = { diagnoseViewModel.fetchTestPushResult() }, onDismissDialog = { diagnoseViewModel.dismissDialog() }, - showTestPushButton = isGooglePlayServicesAvailable || useUnifiedPush, + showTestPushButton = isGooglePlayServicesAvailable || + useUnifiedPush || + useEmbeddedDistrib, isOnline = isOnline ) } @@ -255,7 +259,7 @@ class DiagnoseActivity : BaseActivity() { value = Build.VERSION.SDK_INT.toString() ) - if (isGooglePlayServicesAvailable) { + if (isGooglePlayServicesAvailable || useEmbeddedDistrib) { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnose_gplay_available_title), value = context.resources.getString(R.string.nc_diagnose_gplay_available_yes) @@ -302,7 +306,7 @@ class DiagnoseActivity : BaseActivity() { value = translateBoolean(useUnifiedPush) ) - if (useUnifiedPush) { + if (useUnifiedPush || useEmbeddedDistrib) { setupAppValuesForPush() setupAppValuesForUnifiedPush() } else if (isGooglePlayServicesAvailable) { diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 1c4a54636b..43aa3b0bbc 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -378,7 +378,9 @@ class SettingsActivity : @SuppressLint("StringFormatInvalid") @Suppress("LongMethod") private fun setupNotificationPermissionSettings() { - if (ClosedInterfaceImpl().isGooglePlayServicesAvailable || appPreferences.useUnifiedPush) { + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable || + appPreferences.useUnifiedPush || + UnifiedPushUtils.hasEmbeddedDistributor(context)) { binding.settingsPushOnlyWrapper.visibility = View.VISIBLE binding.settingsGplayNotAvailable.visibility = View.GONE binding.settingsPushNotAvailable.visibility = View.GONE