From 9fa046bfe94b6725164acbfc3fa2aebc4648e400 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 9 Dec 2025 17:14:22 +0100 Subject: [PATCH 01/47] 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 730d2e8f136..dd814b27043 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,12 @@ allprojects { repositories { google() mavenCentral() - maven { url = 'https://jitpack.io' } + maven { + url = 'https://jitpack.io' + content { + includeGroupByRegex("com\\.github\\..*") + } + } } } From 5224a6c1b479762da6ed79818456d34d383285da Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 09:51:42 +0100 Subject: [PATCH 02/47] Add UnifiedPush lib Signed-off-by: sim --- app/build.gradle.kts | 10 ++++++++++ gradle/verification-metadata.xml | 2 ++ 2 files changed, 12 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d444fb1cd32..9b110342278 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -177,6 +177,14 @@ 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 + val protobufJava = "com.google.protobuf:protobuf-java:4.28.2" + resolutionStrategy { + force(protobufJava) + dependencySubstitution { + substitute(module("com.google.protobuf:protobuf-javalite")) + .using(module(protobufJava)) + } + } } dependencies { @@ -322,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.2") + // compose implementation(platform("androidx.compose:compose-bom:2026.03.01")) implementation("androidx.compose.ui:ui") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a3a2a8588b5..e6cb2dbfe13 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -261,6 +261,7 @@ + @@ -272,6 +273,7 @@ + From 165e8d4c51bde2a704adbaff6008f161d377d9ec Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 10:23:42 +0100 Subject: [PATCH 03/47] 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 a94ec01044a..34443960e5b 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 957abe921e3..1f2d7d7f193 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 0dd20c3fa627f9956d8843bf40500f2aee5d98ab Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 10:34:08 +0100 Subject: [PATCH 04/47] 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 b8d66819616..f9924e86b2f 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 00000000000..51976632936 --- /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 00000000000..080516b5fc7 --- /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 00000000000..247514db1ce --- /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 b338e076cda..5a053b597cf 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -389,6 +389,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 1f35fb768091681ae381b156dde659af8cc61cb6 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 11:39:07 +0100 Subject: [PATCH 05/47] 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 43989283290..f774b4933ce 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 @@ -317,11 +318,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 71184fabba4..797b5bef32d 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 48bdd67412d..f92c3f0dbaf 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() } @@ -627,6 +639,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 c1e855a307c..fc10e260d09 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -240,6 +240,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 bb105268fca5d072f601f8a0cdbc5b2f8c678b4e Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 14:10:25 +0100 Subject: [PATCH 06/47] 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 f774b4933ce..a6659c01d90 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -324,6 +324,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. @@ -331,12 +336,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 @@ -344,6 +344,7 @@ class SettingsActivity : binding.settingsUnifiedpushSwitch.setOnClickListener { val checked = binding.settingsUnifiedpushSwitch.isChecked appPreferences.useUnifiedPush = checked + setupNotificationPermissionSettings() } } } @@ -351,8 +352,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() @@ -434,8 +437,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 fc10e260d09..67748cbfa22 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -276,7 +276,7 @@ @@ -362,6 +362,20 @@ android:text="@string/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 f87f830e074a21802e86fe92c647b988b581614e Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 15:51:39 +0100 Subject: [PATCH 07/47] Add UnifiedPush to diagnose activity Signed-off-by: sim --- .../talk/diagnosis/DiagnosisActivity.kt | 53 +++++++++++++++++-- .../diagnosis/DiagnosisContentComposable.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/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index 130d577383f..3ac34cbf9d6 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.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 DiagnosisActivity : 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 DiagnosisElement { data class DiagnosisHeadline(val headline: String) : DiagnosisElement() data class DiagnosisEntry(val key: String, val value: String) : DiagnosisElement() @@ -97,6 +103,11 @@ class DiagnosisActivity : 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 DiagnosisActivity : BaseActivity() { viewState = viewState, onTestPushClick = { diagnosisViewModel.fetchTestPushResult() }, onDismissDialog = { diagnosisViewModel.dismissDialog() }, - isGooglePlayServicesAvailable = isGooglePlayServicesAvailable, + showTestPushButton = isGooglePlayServicesAvailable || useUnifiedPush, isOnline = isOnline ) } @@ -251,9 +262,13 @@ class DiagnosisActivity : BaseActivity() { } else { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnosis_gplay_available_title), - value = context.resources.getString(R.string.nc_diagnosis_gplay_available_no) + value = context.resources.getString(R.string.nc_diagnosis_gplay_available_no_short) ) } + addDiagnosisEntry( + key = getString(R.string.nc_diagnosis_unifiedpush_available_title), + value = getString(R.string.nc_diagnosis_unifiedpush_available_n).format(nUnifiedPushServices) + ) } @SuppressLint("SetTextI18n") @@ -276,7 +291,21 @@ class DiagnosisActivity : BaseActivity() { value = BuildConfig.FLAVOR ) - if (isGooglePlayServicesAvailable) { + addDiagnosisEntry( + key = getString(R.string.nc_diagnosis_offer_unifiedpush), + value = getStringForBoolean(offerUnifiedPush) + ) + + addDiagnosisEntry( + key = getString(R.string.nc_diagnosis_use_unifiedpush), + value = getStringForBoolean(useUnifiedPush) + ) + + if (useUnifiedPush) { + setupAppValuesForPush() + setupAppValuesForUnifiedPush() + } else if (isGooglePlayServicesAvailable) { + setupAppValuesForPush() setupAppValuesForGooglePlayServices() } @@ -286,8 +315,7 @@ class DiagnosisActivity : BaseActivity() { ) } - @Suppress("Detekt.LongMethod") - private fun setupAppValuesForGooglePlayServices() { + private fun setupAppValuesForPush() { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnosis_battery_optimization_title), value = if (PowerManagerUtils().isIgnoringBatteryOptimizations()) { @@ -324,7 +352,17 @@ class DiagnosisActivity : BaseActivity() { NotificationUtils.isMessagesNotificationChannelEnabled(this) ) ) + } + private fun setupAppValuesForUnifiedPush() { + addDiagnosisEntry( + key = getString(R.string.nc_diagnosis_unifiedpush_service), + value = unifiedPushService + ) + } + + @Suppress("Detekt.LongMethod") + private fun setupAppValuesForGooglePlayServices() { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnosis_firebase_push_token_title), value = if (appPreferences.pushToken.isNullOrEmpty()) { @@ -389,6 +427,11 @@ class DiagnosisActivity : BaseActivity() { getStringForBoolean(currentUser.capabilities?.notificationsCapability?.features?.isNotEmpty()) ) + addDiagnosisEntry( + key = getString(R.string.nc_diagnosis_server_supports_webpush), + value = getStringForBoolean(currentUser.hasWebPushCapability) + ) + if (isGooglePlayServicesAvailable) { setupPushRegistrationDiagnosis() } diff --git a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisContentComposable.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisContentComposable.kt index 0a327815bd7..869e804117b 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisContentComposable.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisContentComposable.kt @@ -59,7 +59,7 @@ fun DiagnosisContentComposable( viewState: NotificationUiState, onTestPushClick: () -> Unit, onDismissDialog: () -> Unit, - isGooglePlayServicesAvailable: Boolean, + showTestPushButton: Boolean, isOnline: Boolean ) { val context = LocalContext.current @@ -102,7 +102,7 @@ fun DiagnosisContentComposable( } } } - 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 b3de041c34c..d220210b793 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,6 +225,13 @@ How to translate with transifex: Google Play services Google Play services are available Google Play services are not available. Notifications are not supported + Google Play services are not available. + UnifiedPush services + %d service(s) available + Offer UnifiedPush + Use UnifiedPush + UnifiedPush service + Server supports webpush? 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! From ad8b6bc4fc2f29c171b39b1a1d0b612324865899 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 17:30:41 +0100 Subject: [PATCH 08/47] 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 80eefee6506..00000000000 --- 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 00000000000..545a7f13fd3 --- /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 a6659c01d90..83ac6839fc4 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 @@ -345,6 +346,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 00000000000..e1be9b6024b --- /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 6f83476f9cf526a4dafe04d03cd6c2eb9bb96e20 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 14 Jan 2026 17:54:14 +0100 Subject: [PATCH 09/47] 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 83ac6839fc4..a32436fed42 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -338,13 +338,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 -> @@ -829,6 +830,7 @@ class SettingsActivity : listOf( settingsShowEcosystemSwitch, settingsShowNotificationWarningSwitch, + settingsUnifiedpushSwitch, settingsScreenLockSwitch, settingsScreenSecuritySwitch, settingsIncognitoKeyboardSwitch, From 9ac712e527876b8a86b3f3029891c8f04b499ef8 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 11:03:53 +0100 Subject: [PATCH 10/47] 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 545a7f13fd3..c64ca9ae802 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 b42184d3009dd807e7237b4768cd6dd6e3c9504e Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 14:47:20 +0100 Subject: [PATCH 11/47] 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 f9924e86b2f..88346653094 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 678b81e736aa218ebb585b83ca8baf336d910f7c Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 14:50:33 +0100 Subject: [PATCH 12/47] 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 c64ca9ae802..5f4d0e3a8d9 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 430631caaa8def372999515d40aab5ab54f7c636 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 14:57:01 +0100 Subject: [PATCH 13/47] 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 5f4d0e3a8d9..512870d3ffa 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 e1be9b6024b..a28e47061c2 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 0e321efee35ce033c80fc0d62b194e2ec7cbb9be Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 15:35:01 +0100 Subject: [PATCH 14/47] 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 5da4d1fc8da..cbceceb49c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -327,6 +327,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 a28e47061c2..974561d0cd9 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 f46767babf2bc2891437558de346b4e5acc83572 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 15:41:13 +0100 Subject: [PATCH 15/47] 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 973b615c8b1..302a47875d9 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 3bd66ec0057d3f037784f98e8a7008c773b90d25 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 16:44:07 +0100 Subject: [PATCH 16/47] 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 b705a8f8186..b2a784bbc60 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -52,7 +52,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 @@ -138,7 +138,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 @@ -163,12 +163,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) @@ -206,20 +206,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 @@ -270,7 +270,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 = @@ -294,7 +294,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor sendNotification(pushMessage.timestamp.toInt(), notification) - checkIfCallIsActive(signatureVerification, conversation) + checkIfCallIsActive(conversation) } chatNetworkDataSource?.getRoom(userBeingCalled, roomToken = pushMessage.id!!) @@ -322,10 +322,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( @@ -339,15 +336,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 ) @@ -360,6 +366,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) @@ -378,13 +385,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() ) ) @@ -533,7 +538,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? = "" @@ -564,7 +569,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val activeStatusBarNotification = findNotificationForRoom( context, - signatureVerification.user!!, + user, pushMessage.id!! ) @@ -615,7 +620,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!!) @@ -640,7 +645,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 } @@ -726,12 +731,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!!, @@ -753,7 +758,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) @@ -942,13 +947,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) ) @@ -958,7 +963,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor credentials, ApiUtils.getUrlForCall( apiVersion, - signatureVerification.user!!.baseUrl!!, + user.baseUrl!!, pushMessage.id!! ) ) @@ -976,7 +981,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 @@ -1072,7 +1077,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 f042ca93591..7b8766980ab 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 8b12a483f03..e5966dbae44 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 33f5a7b8390a9ebe1528c82cacdb42779c1a34ff Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 17:15:55 +0100 Subject: [PATCH 17/47] 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 a32436fed42..f01201e68a6 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -340,6 +340,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 { @@ -347,15 +348,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 974561d0cd9..23fd5832d25 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 67748cbfa22..f24e82db6e6 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -275,6 +275,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 9c383924bd5df74f7f355612134517f8c0b3bd35 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 15 Jan 2026 17:43:43 +0100 Subject: [PATCH 18/47] 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 302a47875d9..2c5c7157e46 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 15bc6e0ff20802fe9cae1a618d4c93ec96892814 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 09:22:24 +0100 Subject: [PATCH 19/47] 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 2c5c7157e46..340a14fe723 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 29100b4ac55f50e3d4970b425a950faf00a433d9 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 09:24:32 +0100 Subject: [PATCH 20/47] 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 340a14fe723..03674f8e39f 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 a3550c6d9d9d57481e629c1a91092f8164459a80 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 13:02:06 +0100 Subject: [PATCH 21/47] 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 03674f8e39f..a504caa36cc 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 95e59b21481..bf0d53aeaa2 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 0b150e27fc7e727d2648ac264ed770d16c5f4d7c Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 14:49:40 +0100 Subject: [PATCH 22/47] 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 f600c227ad4..8ed244f24da 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 1dbd636d64a34ae54eae7b37fe6aca990656d77c Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:04:08 +0100 Subject: [PATCH 23/47] 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 8ed244f24da..6d0417e7f69 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 89f48abd76091c92883fe6b4eaaf5a2d6552a2bb Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:10:57 +0100 Subject: [PATCH 24/47] 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 6d0417e7f69..b9e248fcd28 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 a5f812b64f45a60bfbe21779be24e5a0098594bf Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:12:45 +0100 Subject: [PATCH 25/47] 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 b9e248fcd28..ed06ba115ef 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 c8470903b9f..f8e1af6b16e 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 c13a1bbbe3c0fb3b80aa28d83239d61fd448e178 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:15:56 +0100 Subject: [PATCH 26/47] 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 ed06ba115ef..bd1ec31ddb8 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 b8449ee9e7c9669492e5b85f4f403db841339eee Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 15:47:28 +0100 Subject: [PATCH 27/47] 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 bd1ec31ddb8..8486cc51d66 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 23fd5832d25..90ddafe3f22 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 ece204370f498ebd50bd26deac2ae872d2691e40 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 16:28:15 +0100 Subject: [PATCH 28/47] 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 4c5c6b50dcb..d42d82c8bb4 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 90ddafe3f22..047fa3da70b 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 8d0ef2e7703216c17de94aaca2dbb48614122c99 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 16 Jan 2026 17:36:32 +0100 Subject: [PATCH 29/47] 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 8486cc51d66..bc2e7b0788f 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 d53534214043fb8a12eb57362235ca376e4e0e97 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 07:49:02 +0100 Subject: [PATCH 30/47] 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 bc2e7b0788f..7f9f7c1bd2c 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 0e121d779bc7a1dd4b1b660ac8de9b140e1dfe6c Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:14:54 +0100 Subject: [PATCH 31/47] 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 7f9f7c1bd2c..5ce1302925f 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 2327fe8f761c06b31323b128668ab241439a8708 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:29:06 +0100 Subject: [PATCH 32/47] 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 f01201e68a6..82840f8927f 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -339,6 +339,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 d08af61c84037069a28ab42a896a2a2afdfe5116 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:49:00 +0100 Subject: [PATCH 33/47] 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 f0b21622d9f..a7012bc5eb1 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -304,7 +304,8 @@ class ConversationsListActivity : BaseActivity() { // 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 befa400d4b15e505c16fa04acb480544c171d14c Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 09:15:59 +0100 Subject: [PATCH 34/47] Show latest endpoint reception in diagnose Signed-off-by: sim --- .../nextcloud/talk/diagnosis/DiagnosisActivity.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/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index 3ac34cbf9d6..e773903c20d 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -359,6 +359,20 @@ class DiagnosisActivity : BaseActivity() { key = getString(R.string.nc_diagnosis_unifiedpush_service), value = unifiedPushService ) + + addDiagnosisEntry( + key = context.resources.getString(R.string.nc_diagnosis_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 a504caa36cc..47d91a5c423 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 797b5bef32d..f1d8476c1b1 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 f92c3f0dbaf..dfb2e266bad 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() } @@ -640,6 +652,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 ab1e52f0592..329176a4c6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -232,6 +232,7 @@ How to translate with transifex: Offer UnifiedPush Use UnifiedPush UnifiedPush service + Latest endpoint received Server supports webpush? UnifiedPush is disabled and Google Play services are not available. Notifications are not supported Battery settings From 8a322710ec5aeeb011fd2e63b20bf471dabde478 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 15:57:35 +0100 Subject: [PATCH 35/47] 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 7b8766980ab..30a9219c948 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 146763868b677cab6f3560b110f87b6d171b15cd Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 19:15:18 +0100 Subject: [PATCH 36/47] 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 47d91a5c423..f3a348c8ad9 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 30a9219c948..b21829b2b76 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 b2aaf9033c0d1d53e06cd748fab238a3d8799419 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 19:16:01 +0100 Subject: [PATCH 37/47] 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 b2a784bbc60..3699c70c570 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -181,9 +181,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.") } @@ -191,7 +193,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) @@ -381,6 +383,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 @@ -567,11 +570,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. @@ -1107,6 +1112,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 f3a348c8ad9..29df873fb51 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 e3bb3cc74ec..6b519de312c 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 9b725a43217e71a231a03cca55ddf2e0bd20f54d Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 19:17:13 +0100 Subject: [PATCH 38/47] 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 29df873fb51..ebcb531afc0 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 5cc0658e2385a35875bba8d8ee395bc63e5e38e2 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 21:20:43 +0100 Subject: [PATCH 39/47] 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 5ce1302925f..d81d6b2ea5c 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 00000000000..25648eb508e --- /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 047fa3da70b..f43211b0b86 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 4e64db5ed16..8e3808b927d 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 329176a4c6b..118f7daf212 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 e9bc02894ee62f1176bc49106bdbd6f774bbb363 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 21:25:44 +0100 Subject: [PATCH 40/47] 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 ebcb531afc0..c8ab1fcd51a 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 2e9d6e40138a4042e345735ccf4c77d9aa0d5eef Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 21:27:45 +0100 Subject: [PATCH 41/47] 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 f43211b0b86..f84f457e634 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 8fe657cc86c39916ce1a673c9acafc8eb2ed4b9d Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 21:38:10 +0100 Subject: [PATCH 42/47] 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 c8ab1fcd51a..20a74cb947b 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 e8f3c3bd8ba542ca116699a94418dd0e8ea2908b Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 13 Apr 2026 15:51:23 +0200 Subject: [PATCH 43/47] Fix baseUrl after rebase Signed-off-by: sim --- app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3699c70c570..193833070e3 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -514,7 +514,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val mimetype = param["mimetype"].orEmpty() val fileId = param["id"] if (mimetype.startsWith("image/") && fileId != null) { - val baseUrl = signatureVerification.user!!.baseUrl!! + val baseUrl = user.baseUrl!! val px = context!!.resources.displayMetrics.widthPixels imagePreviewUrl = ApiUtils.getUrlForFilePreviewWithFileId(baseUrl, fileId, px) imageMimeType = mimetype From 1cf7a5e603f35488ba6548902252cea0e3d02f31 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 13 Apr 2026 15:53:10 +0200 Subject: [PATCH 44/47] Fix after rebase: Diagnosis string name Signed-off-by: sim --- app/src/main/res/layout/activity_settings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index f24e82db6e6..b0fe73a0fff 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -402,7 +402,7 @@ + android:text="@string/nc_diagnosis_push_available_no"/> UnifiedPush service Latest endpoint received Server supports webpush? - UnifiedPush is disabled and 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 15231055bc5ea2e302521d349f3d181ac0792121 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:02:05 +0100 Subject: [PATCH 45/47] Add embedded distributor to use Play Services with the generic flavor Signed-off-by: sim --- app/build.gradle.kts | 1 + .../account/AccountVerificationActivity.kt | 29 +++++++++++++++++-- .../talk/diagnosis/DiagnosisActivity.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.kts b/app/build.gradle.kts index 9b110342278..794e8de371a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -331,6 +331,7 @@ dependencies { "gplayImplementation"("com.google.firebase:firebase-messaging:25.0.1") implementation("org.unifiedpush.android:connector:3.3.2") + genericImplementation("org.unifiedpush.android:embedded-fcm-distributor:3.1.0-rc1") // compose implementation(platform("androidx.compose:compose-bom:2026.03.01")) 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 d81d6b2ea5c..48295356013 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/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index e773903c20d..0b711122529 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.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 DiagnosisActivity : 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 20a74cb947b..d8e1092503f 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 82840f8927f..ec3a7e81c9b 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -326,7 +326,7 @@ class SettingsActivity : } private fun showUnifiedPushToggle(): Boolean { - return UnifiedPush.getDistributors(this).isNotEmpty() && + return UnifiedPushUtils.getExternalDistributors(this).isNotEmpty() && userManager.users.blockingGet().all { it.hasWebPushCapability } } @@ -341,7 +341,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 f84f457e634..d7ebdd031a5 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 e6cb2dbfe13..facdb839c38 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -273,7 +273,10 @@ - + + + + From d780812abd6e1b4b1185b6cf606fccea71f1b6d2 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:49:44 +0100 Subject: [PATCH 46/47] 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 a7012bc5eb1..e128cbc3e34 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -84,6 +84,7 @@ import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.ShareUtils 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 @@ -305,7 +306,8 @@ class ConversationsListActivity : BaseActivity() { 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 5209cfddb165625623c03c0ed86453ef3998ed77 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 17 Jan 2026 08:56:32 +0100 Subject: [PATCH 47/47] Fix settings & diagnose with embedded distrib Signed-off-by: sim --- .../com/nextcloud/talk/diagnosis/DiagnosisActivity.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/diagnosis/DiagnosisActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt index 0b711122529..bec50b1a2a8 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnosis/DiagnosisActivity.kt @@ -80,6 +80,7 @@ class DiagnosisActivity : 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 DiagnosisActivity : 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 DiagnosisActivity : BaseActivity() { viewState = viewState, onTestPushClick = { diagnosisViewModel.fetchTestPushResult() }, onDismissDialog = { diagnosisViewModel.dismissDialog() }, - showTestPushButton = isGooglePlayServicesAvailable || useUnifiedPush, + showTestPushButton = isGooglePlayServicesAvailable || + useUnifiedPush || + useEmbeddedDistrib, isOnline = isOnline ) } @@ -255,7 +259,7 @@ class DiagnosisActivity : BaseActivity() { value = Build.VERSION.SDK_INT.toString() ) - if (isGooglePlayServicesAvailable) { + if (isGooglePlayServicesAvailable || useEmbeddedDistrib) { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnosis_gplay_available_title), value = context.resources.getString(R.string.nc_diagnosis_gplay_available_yes) @@ -302,7 +306,7 @@ class DiagnosisActivity : BaseActivity() { value = getStringForBoolean(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 ec3a7e81c9b..bd6ad68b4e4 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -387,7 +387,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