From 144df1dee8c617ff7962cddc83799bc49bad2246 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 6e8fdd5950..4a69a0408f 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 4ba7e206428006b490b9b469ec526093146dc52e 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 2db53d47d9..13726ccedd 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 { @@ -323,6 +331,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.04.01")) implementation("androidx.compose.ui:ui") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 2d8ea1812a..1d823fff3e 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -263,6 +263,7 @@ + @@ -274,6 +275,7 @@ + From 4cefe7e0b15cd243ecf3d345fc3677c66144154f 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 a94ec01044..34443960e5 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt @@ -35,6 +35,9 @@ data class User( var scheduledForDeletion: Boolean = FALSE ) : Parcelable { + val hasWebPushCapability: Boolean + get() = capabilities?.notificationsCapability?.push?.contains("webpush") == true + fun getCredentials(): String = ApiUtils.getCredentials(username, token)!! fun hasSpreedFeatureCapability(capabilityName: String): Boolean { diff --git a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt index 957abe921e..1f2d7d7f19 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt @@ -18,8 +18,10 @@ import kotlinx.serialization.Serializable @Serializable data class NotificationsCapability( @JsonField(name = ["ocs-endpoints"]) - var features: List? + var features: List?, + @JsonField(name = ["push"]) + var push: List? ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' - constructor() : this(null) + constructor() : this(null, null) } From 97c8d04ba5c667ccd23373a012d5d69826f8ab0d 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 b8d6681961..f9924e86b2 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -27,6 +27,7 @@ import com.nextcloud.talk.models.json.participants.ParticipantsOverall; import com.nextcloud.talk.models.json.participants.TalkBanOverall; import com.nextcloud.talk.models.json.push.PushRegistrationOverall; +import com.nextcloud.talk.models.json.push.VapidOverall; import com.nextcloud.talk.models.json.reactions.ReactionsOverall; import com.nextcloud.talk.models.json.reminder.ReminderOverall; import com.nextcloud.talk.models.json.search.ContactsByNumberOverall; @@ -270,6 +271,32 @@ Observable setUserData(@Header("Authorization") String authoriza @GET Observable getServerStatus(@Url String url); + @GET + Observable getVapidKey( + @Header("Authorization") String authorization, + @Url String url); + + @FormUrlEncoded + @POST + Observable registerWebPush( + @Header("Authorization") String authorization, + @Url String url, + @Field("endpoint") String endpoint, + @Field("uaPublicKey") String uaPublicKey, + @Field("auth") String auth, + @Field("appTypes") String appTypes); + + @FormUrlEncoded + @POST + Observable activateWebPush( + @Header("Authorization") String authorization, + @Url String url, + @Field("activationToken") String activationToken); + + @DELETE + Observable unregisterWebPush( + @Header("Authorization") String authorization, + @Url String url); /* QueryMap items are as follows: diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt new file mode 100644 index 0000000000..5197663293 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.push + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class Vapid( + @JsonField(name = ["vapid"]) + var vapid: String? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt new file mode 100644 index 0000000000..080516b5fc --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.push + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.generic.GenericMeta +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class VapidOCS( + @JsonField(name = ["meta"]) + var meta: GenericMeta?, + @JsonField(name = ["data"]) + var data: Vapid? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt new file mode 100644 index 0000000000..247514db1c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.push + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class VapidOverall( + @JsonField(name = ["ocs"]) + var ocs: VapidOCS? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index b338e076cd..5a053b597c 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 b17346543c4a1efe89edff39502e05eeb97972b8 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 4398928329..f774b4933c 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 71184fabba..797b5bef32 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 48bdd67412..f92c3f0dba 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 c1e855a307..fc10e260d0 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 2ab5f82c28f38c393f70357ede0a5bc7ee6125db 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 f774b4933c..a6659c01d9 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 fc10e260d0..67748cbfa2 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 31c661e6e71b121f8eccc5cb602f22f0920bbeb7 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 130d577383..3ac34cbf9d 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 0a327815bd..869e804117 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 c2bb415012..1853b2302b 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 1f9ed5301d0e3495ff0d1b42a8537241a6ef2659 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 80eefee650..0000000000 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Andy Scherzinger - * SPDX-FileCopyrightText: 2022 Marcel Hibbe - * SPDX-FileCopyrightText: 2017 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.jobs; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.work.Data; -import androidx.work.Worker; -import androidx.work.WorkerParameters; -import autodagger.AutoInjector; -import okhttp3.CookieJar; -import okhttp3.OkHttpClient; -import retrofit2.Retrofit; - -import com.nextcloud.talk.api.NcApi; -import com.nextcloud.talk.application.NextcloudTalkApplication; -import com.nextcloud.talk.utils.ClosedInterfaceImpl; -import com.nextcloud.talk.utils.PushUtils; - -import java.net.CookieManager; - -import javax.inject.Inject; - -@AutoInjector(NextcloudTalkApplication.class) -public class PushRegistrationWorker extends Worker { - public static final String TAG = "PushRegistrationWorker"; - public static final String ORIGIN = "origin"; - - @Inject - Retrofit retrofit; - - @Inject - OkHttpClient okHttpClient; - - public PushRegistrationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result doWork() { - NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this); - if (new ClosedInterfaceImpl().isGooglePlayServicesAvailable()) { - Data data = getInputData(); - String origin = data.getString("origin"); - Log.d(TAG, "PushRegistrationWorker called via " + origin); - - NcApi ncApi = retrofit - .newBuilder() - .client(okHttpClient - .newBuilder() - .cookieJar(CookieJar.NO_COOKIES) - .build()) - .build() - .create(NcApi.class); - - PushUtils pushUtils = new PushUtils(); - pushUtils.generateRsa2048KeyPair(); - pushUtils.pushRegistrationToServer(ncApi); - - return Result.success(); - } - Log.w(TAG, "executing PushRegistrationWorker doesn't make sense because Google Play Services are not " + - "available"); - return Result.failure(); - } -} diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt new file mode 100644 index 0000000000..545a7f13fd --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -0,0 +1,229 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2022 Marcel Hibbe + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.jobs; + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import androidx.work.Worker +import androidx.work.WorkerParameters +import autodagger.AutoInjector +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.generic.Status +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.ClosedInterfaceImpl +import com.nextcloud.talk.utils.PushUtils +import com.nextcloud.talk.utils.preferences.AppPreferences +import io.reactivex.Observable +import okhttp3.CookieJar +import okhttp3.OkHttpClient +import org.unifiedpush.android.connector.UnifiedPush +import retrofit2.Retrofit +import java.net.CookieManager +import javax.inject.Inject + +@AutoInjector(NextcloudTalkApplication::class) +class PushRegistrationWorker( + context: Context, + workerParams: WorkerParameters +): Worker(context, workerParams) { + @Inject + lateinit var retrofit: Retrofit + + @Inject + lateinit var okHttpClient: OkHttpClient + + @Inject + lateinit var preferences: AppPreferences + + @Inject + lateinit var userManager: UserManager + + lateinit var ncApi: NcApi + + private fun inject() { + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + ncApi = retrofit + .newBuilder() + .client( + okHttpClient + .newBuilder() + .cookieJar(CookieJar.NO_COOKIES) + .build() + ) + .build() + .create(NcApi::class.java) + } + + @SuppressLint("CheckResult") + override fun doWork(): Result { + inject() + val origin = inputData.getString(ORIGIN) + val useUnifiedPush = inputData.getBoolean(USE_UNIFIEDPUSH, defaultUseUnifiedPush()) + Log.d(TAG, "PushRegistrationWorker called via $origin (up=$useUnifiedPush)") + + if (useUnifiedPush) { + registerUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) + // unregister proxy push for user setting up web push for the first time + .flatMap { user -> unregisterProxyPush(user)} + } else { + unregisterUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) + .toList() + .subscribe { _, _ -> + registerProxyPush() + } + } + return Result.success() + } + + private fun defaultUseUnifiedPush(): Boolean = preferences.useUnifiedPush && + // If this is the first registration, we have never called [UnifiedPush.register] + // because it happens after this function + // => we can't be acked by the distributor yet, [UnifiedPush.getAckDistributor] == null + // So we check the SavedDistributor instead + UnifiedPush.getSavedDistributor(applicationContext).also { + if (it == null) { + Log.d(TAG, "No saved distributor found: disabling UnifiedPush") + preferences.useUnifiedPush = false + } + } != null + + /** + * Register proxy push for all accounts with [User.usesProxyPush], set if + * the server doesn't support webpush or if UnifiedPush is disabled + */ + private fun registerProxyPush() { + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + Log.d(TAG, "Registering proxy push") + val pushUtils = PushUtils() + pushUtils.generateRsa2048KeyPair() + pushUtils.pushRegistrationToServer(ncApi) + } + } + + private fun unregisterProxyPush(user: User): Observable? { + return if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + Log.d(TAG, "Unregistering proxy push for ${user.userId}") + ncApi.unregisterDeviceForNotificationsWithNextcloud( + user.getCredentials(), + ApiUtils.getUrlNextcloudPush(user.baseUrl!!) + ).flatMap { + val pushConfig = user.pushConfigurationState!! + val queryMap = hashMapOf( + "deviceIdentifier" to pushConfig.deviceIdentifier, + "userPublicKey" to pushConfig.userPublicKey, + "deviceIdentifierSignature" to pushConfig.deviceIdentifierSignature + ) + ncApi.unregisterDeviceForNotificationsWithProxy(ApiUtils.getUrlPushProxy(), queryMap) + } + } else { + null + } + } + + fun unregisterUnifiedPushForAllAccounts( + context: Context, + userManager: UserManager, + ncApi: NcApi + ): Observable { + val obs = userManager.users.blockingGet().mapNotNull { user -> + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return@mapNotNull null + } + UnifiedPush.unregister(context, user.userId!!) + if (user.usesWebPush) { + user.usesWebPush = false + userManager.saveUser(user) + ncApi.unregisterWebPush(user.getCredentials(), ApiUtils.getUrlForWebPush(user.baseUrl!!)) + } else { + return@mapNotNull null + } + } + return Observable.merge(obs) + } + + /** + * Register UnifiedPush for all accounts with the server VAPID key if the server supports web push + * + * Web push is registered on the nc server when the push endpoint is received + * + * Proxy push is unregistered for accounts on server with web push support, if a server doesn't support web push, proxy push is re-registered + * + * @return Observable not null if user was using proxy push and now use web push + */ + fun registerUnifiedPushForAllAccounts( + context: Context, + userManager: UserManager, + ncApi: NcApi + ): Observable { + val obs = userManager.users.blockingGet().map { user -> + registerUnifiedPushForAccount(context, ncApi, user) + } + return Observable.merge(obs) + // We do not update the user push proxy setting on error + .flatMap { res -> + val user = res.first + val wasUsingProxyPush = user.usesProxyPush + user.usesWebPush = !res.second + userManager.saveUser(user) + Log.d(TAG, "User ${user.userId} updated: wasUsingProxy=$wasUsingProxyPush, now=${user.usesProxyPush}") + if (wasUsingProxyPush && !user.usesProxyPush) { + Observable.just(user) + } else { + Observable.just(null) + } + } + } + + /** + * Register UnifiedPush with the server VAPID key if the server supports web push + * + * Web push is registered on the nc server when the push endpoint is received + * + * @return `Observable`, true if registration succeed, false if server doesn't support web push + */ + private fun registerUnifiedPushForAccount( + context: Context, + ncApi: NcApi, + user: User + ): Observable>? { + if (user.hasWebPushCapability) { + Log.d(TAG, "Registering web push for ${user.userId}") + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return null + } + return ncApi.getVapidKey(user.getCredentials(),ApiUtils.getUrlForVapid(user.baseUrl!!)) + .flatMap { ocs -> + ocs.ocs?.data?.vapid?.let { vapid -> + UnifiedPush.register( + context, + instance = user.userId!!, + messageForDistributor = user.userId, + vapid = vapid + ) + Observable.just(user to true) + } + } + } else { + Log.d(TAG, "${user.userId}'s server doesn't support web push") + return Observable.just(user to false) + } + } + + companion object { + const val TAG = "PushRegistrationWorker" + const val ORIGIN = "origin" + const val USE_UNIFIEDPUSH = "use_unifiedpush" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index a6659c01d9..83ac6839fc 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 0000000000..e1be9b6024 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -0,0 +1,96 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.utils + +import android.app.Activity +import android.content.Context +import android.util.Log +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.nextcloud.talk.jobs.PushRegistrationWorker +import org.unifiedpush.android.connector.UnifiedPush + +object UnifiedPushUtils { + private val TAG: String = UnifiedPushUtils::class.java.getSimpleName() + + /** + * Use default distributor, register all accounts that support webpush + * + * Unregister proxy push for account if succeed + * Re-register proxy push for the others + * + * @param activity: Context needs to be an activity, to get a result + * @param userManager: Used to register all accounts + * @param ncApi: API + * @param callback: run with the push service name if available + */ + @JvmStatic + fun useDefaultDistributor( + activity: Activity, + callback: (String?) -> Unit + ) { + Log.d(TAG, "Using default UnifiedPush distributor") + UnifiedPush.tryUseCurrentOrDefaultDistributor(activity as Context) { res -> + if (res) { + enqueuePushWorker(activity, true, "useDefaultDistributor") + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + callback(null) + } + } + } + + /** + * Pick another distributor, register all accounts that support webpush + * + * Unregister proxy push for account if succeed + * Re-register proxy push for the others + * + * @param activity: Context needs to be an activity, to get a result + * @param accountManager: Used to register all accounts + * @param callback: run with the push service name if available + */ + /*@JvmStatic + fun pickDistributor( + activity: Activity, + callback: (String?) -> Unit + ) { + Log.d(TAG, "Picking another UnifiedPush distributor") + UnifiedPush.tryPickDistributor(activity as Context) { res -> + if (res) { + enqueuePushWorker(activity, true, "useDefaultDistributor") + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + callback(null) + } + } + }*/ + + /** + * Disable UnifiedPush and try to register with proxy push again + */ + @JvmStatic + fun disableExternalUnifiedPush( + context: Context + ) { + enqueuePushWorker(context, false, "disableExternalUnifiedPush") + } + + private fun enqueuePushWorker(context: Context, useUnifiedPush: Boolean, origin: String) { + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushUtils#$origin") + .putBoolean(PushRegistrationWorker.USE_UNIFIEDPUSH, useUnifiedPush) + .build() + val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(context).enqueue(pushRegistrationWork) + + } +} From 9b1d483a1e5a421d6fccfa7ae5f5bbced059b342 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 83ac6839fc..a32436fed4 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 24a0280f52e49bf220d343947db9c857c5818c50 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 545a7f13fd..c64ca9ae80 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -6,7 +6,7 @@ * SPDX-FileCopyrightText: 2017 Mario Danic * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.jobs; +package com.nextcloud.talk.jobs import android.annotation.SuppressLint import android.content.Context @@ -75,11 +75,21 @@ class PushRegistrationWorker( registerUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) // unregister proxy push for user setting up web push for the first time .flatMap { user -> unregisterProxyPush(user)} + .toList() + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while registering for UnifiedPush") + e.printStackTrace() + } + } } else { unregisterUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) .toList() - .subscribe { _, _ -> - registerProxyPush() + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while unregistering from UnifiedPush") + e.printStackTrace() + } ?: registerProxyPush() } } return Result.success() @@ -110,7 +120,7 @@ class PushRegistrationWorker( } } - private fun unregisterProxyPush(user: User): Observable? { + private fun unregisterProxyPush(user: User): Observable { return if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { Log.d(TAG, "Unregistering proxy push for ${user.userId}") ncApi.unregisterDeviceForNotificationsWithNextcloud( @@ -126,7 +136,7 @@ class PushRegistrationWorker( ncApi.unregisterDeviceForNotificationsWithProxy(ApiUtils.getUrlPushProxy(), queryMap) } } else { - null + Observable.empty() } } @@ -165,7 +175,7 @@ class PushRegistrationWorker( context: Context, userManager: UserManager, ncApi: NcApi - ): Observable { + ): Observable { val obs = userManager.users.blockingGet().map { user -> registerUnifiedPushForAccount(context, ncApi, user) } @@ -174,13 +184,13 @@ class PushRegistrationWorker( .flatMap { res -> val user = res.first val wasUsingProxyPush = user.usesProxyPush - user.usesWebPush = !res.second + user.usesProxyPush = !res.second userManager.saveUser(user) Log.d(TAG, "User ${user.userId} updated: wasUsingProxy=$wasUsingProxyPush, now=${user.usesProxyPush}") if (wasUsingProxyPush && !user.usesProxyPush) { Observable.just(user) } else { - Observable.just(null) + Observable.empty() } } } @@ -196,12 +206,12 @@ class PushRegistrationWorker( context: Context, ncApi: NcApi, user: User - ): Observable>? { + ): Observable> { if (user.hasWebPushCapability) { Log.d(TAG, "Registering web push for ${user.userId}") if (user.userId == null || user.baseUrl == null) { Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") - return null + return Observable.empty() } return ncApi.getVapidKey(user.getCredentials(),ApiUtils.getUrlForVapid(user.baseUrl!!)) .flatMap { ocs -> @@ -213,6 +223,9 @@ class PushRegistrationWorker( vapid = vapid ) Observable.just(user to true) + } ?: let { + Log.d(TAG, "No VAPID key found") + Observable.just(user to false) } } } else { From 81ced85d9883b902c489411da5e727847649eeef 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 f9924e86b2..8834665309 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 b9a4a550fd6a44eddb73a613cb20e900e3c68cda 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 c64ca9ae80..5f4d0e3a8d 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -17,20 +17,32 @@ import autodagger.AutoInjector import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.models.json.generic.Status import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.PushUtils import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observable +import io.reactivex.schedulers.Schedulers import okhttp3.CookieJar import okhttp3.OkHttpClient import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint import retrofit2.Retrofit import java.net.CookieManager import javax.inject.Inject +/** + * Can be used for 4 different things: + * - if inputData contains [USER_ID] and [ACTIVATION_TOKEN]: activate web push for user (on server) and unregister + * for proxy push (on server) (received from [com.nextcloud.talk.services.UnifiedPushService]) + * - if inputData contains [UNIFIEDPUSH_ENDPOINT]: register for web push (on server) + * (received from [com.nextcloud.talk.services.UnifiedPushService]) + * - if inputData contains [USE_UNIFIEDPUSH] or if [AppPreferences.getUseUnifiedPush]: get the server VAPID key and + * register for UnifiedPush to the distributor (on device) + * - if [AppPreferences.getUseUnifiedPush] is false: unregister UnifiedPush (on device) and unregister for web push + * (on server), then register for proxy push (on server) + */ @AutoInjector(NextcloudTalkApplication::class) class PushRegistrationWorker( context: Context, @@ -68,33 +80,127 @@ class PushRegistrationWorker( override fun doWork(): Result { inject() val origin = inputData.getString(ORIGIN) + val userId = inputData.getLong(USER_ID, -1) + val activationToken = inputData.getString(ACTIVATION_TOKEN) + //TODO fix dummy + //val pushEndpoint = inputData.getByteArray(UNIFIEDPUSH_ENDPOINT)?.toPushEndpoint() + val pushEndpoint = inputData.getByteArray(UNIFIEDPUSH_ENDPOINT)?.let { + PushEndpoint("http://dummy", null, false) + } val useUnifiedPush = inputData.getBoolean(USE_UNIFIEDPUSH, defaultUseUnifiedPush()) - Log.d(TAG, "PushRegistrationWorker called via $origin (up=$useUnifiedPush)") - - if (useUnifiedPush) { - registerUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) - // unregister proxy push for user setting up web push for the first time - .flatMap { user -> unregisterProxyPush(user)} - .toList() - .subscribe { _, e -> - e?.let { - Log.d(TAG, "An error occurred while registering for UnifiedPush") - e.printStackTrace() - } - } + if (userId != -1L && activationToken != null) { + Log.d(TAG, "PushRegistrationWorker called via $origin (webPushActivationWork)") + webPushActivationWork(userId, activationToken) + } else if (pushEndpoint != null) { + Log.d(TAG, "PushRegistrationWorker called via $origin (webPushWork)") + webPushWork(pushEndpoint) + } else if (useUnifiedPush) { + Log.d(TAG, "PushRegistrationWorker called via $origin (unifiedPushWork)") + unifiedPushWork() } else { - unregisterUnifiedPushForAllAccounts(applicationContext, userManager, ncApi) - .toList() - .subscribe { _, e -> - e?.let { - Log.d(TAG, "An error occurred while unregistering from UnifiedPush") - e.printStackTrace() - } ?: registerProxyPush() - } + Log.d(TAG, "PushRegistrationWorker called via $origin (proxyPushWork)") + proxyPushWork() } return Result.success() } + /** + * Activate web push for user (on server) and unregister for proxy push (on server) + */ + @SuppressLint("CheckResult") + private fun webPushActivationWork(id: Long, activationToken: String) { + val user = userManager.getUserWithId(id).blockingGet() + activateWebPushForAccount(user, activationToken) + .map { res -> + if (res) { + unregisterProxyPush(user) + } else { + Log.d(TAG, "Couldn't activate web push for user ${user.userId}") + Observable.empty() + } + } + .toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while activating web push, or unregistering proxy push") + e.printStackTrace() + } + } + } + + /** + * Register for web push (on server) + */ + @SuppressLint("CheckResult") + private fun webPushWork(pushEndpoint: PushEndpoint) { + val obs = userManager.users.blockingGet().map { user -> + registerWebPushForAccount(user, pushEndpoint) + } + Observable.merge(obs) + .map { (user, res) -> + if (res) { + Log.d(TAG, "User ${user.userId} registered for web push.") + } else { + Log.w(TAG, "Couldn't register ${user.userId} for web push.") + } + }.toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while registering for web push") + e.printStackTrace() + } + } + } + + /** + * Get VAPID key (on server) and register UnifiedPush to the distributor (on device) + */ + @SuppressLint("CheckResult") + private fun unifiedPushWork() { + val obs = userManager.users.blockingGet().map { user -> + registerUnifiedPushForAccount(user) + } + Observable.merge(obs) + .toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while registering for UnifiedPush") + e.printStackTrace() + } + } + } + + /** + * Unregister for UnifiedPush (on device) and web push (on server), and + * register for proxy push (on server) + */ + @SuppressLint("CheckResult") + private fun proxyPushWork() { + val obs = userManager.users.blockingGet().mapNotNull { user -> + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return@mapNotNull null + } + UnifiedPush.unregister(applicationContext, user.userId!!) + // TODO unregisterWebPushForUser + Observable.empty() + } + Observable.merge(obs) + .toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.d(TAG, "An error occurred while unregistering for web push") + e.printStackTrace() + } + // Register proxy push for all account, no matter the result of the web push unregistration + registerProxyPush() + } + } + private fun defaultUseUnifiedPush(): Boolean = preferences.useUnifiedPush && // If this is the first registration, we have never called [UnifiedPush.register] // because it happens after this function @@ -108,8 +214,9 @@ class PushRegistrationWorker( } != null /** - * Register proxy push for all accounts with [User.usesProxyPush], set if - * the server doesn't support webpush or if UnifiedPush is disabled + * Register proxy push for all accounts if the devices support the Play Services + * + * This must not be called when UnifiedPush is enabled. */ private fun registerProxyPush() { if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { @@ -120,6 +227,9 @@ class PushRegistrationWorker( } } + /** + * Unregister on NC server and NC proxy + */ private fun unregisterProxyPush(user: User): Observable { return if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { Log.d(TAG, "Unregistering proxy push for ${user.userId}") @@ -140,59 +250,84 @@ class PushRegistrationWorker( } } - fun unregisterUnifiedPushForAllAccounts( - context: Context, - userManager: UserManager, - ncApi: NcApi - ): Observable { - val obs = userManager.users.blockingGet().mapNotNull { user -> + /** + * Register web push with the unifiedpush endpoint, if the server supports web push + * + * @return `Observable>`, true if registration succeed, false if server doesn't support web push + */ + private fun registerWebPushForAccount( + user: User, + pushEndpoint: PushEndpoint + ): Observable> { + if (user.hasWebPushCapability) { + Log.d(TAG, "Registering web push for ${user.userId}") if (user.userId == null || user.baseUrl == null) { Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") - return@mapNotNull null + return Observable.empty() } - UnifiedPush.unregister(context, user.userId!!) - if (user.usesWebPush) { - user.usesWebPush = false - userManager.saveUser(user) - ncApi.unregisterWebPush(user.getCredentials(), ApiUtils.getUrlForWebPush(user.baseUrl!!)) - } else { - return@mapNotNull null + if (pushEndpoint.pubKeySet == null) { + // Should not happen with default UnifiedPush KeyManager + Log.w(TAG, "Null web push keys for user ${user.userId}, aborting.") + return Observable.empty() + } + return ncApi.registerWebPush( + user.getCredentials(), + ApiUtils.getUrlForWebPush(user.baseUrl!!), + pushEndpoint.url, + pushEndpoint.pubKeySet!!.pubKey, + pushEndpoint.pubKeySet!!.auth, + "talk" + ).map { r -> + return@map when (r.code()) { + 200 -> { + Log.d(TAG, "Web push registration for ${user.userId} was already registered and activated\n") + user to true + } + 201 -> { + Log.d(TAG, "New web push registration for ${user.userId}") + user to true + } + else -> { + Log.d(TAG, "An error occurred while registering web push for ${user.userId} (status=${r.code()})") + user to false + } + } } + } else { + Log.d(TAG, "${user.userId}'s server doesn't support web push") + return Observable.just(user to false) } - return Observable.merge(obs) } - /** - * Register UnifiedPush for all accounts with the server VAPID key if the server supports web push - * - * Web push is registered on the nc server when the push endpoint is received - * - * Proxy push is unregistered for accounts on server with web push support, if a server doesn't support web push, proxy push is re-registered - * - * @return Observable not null if user was using proxy push and now use web push - */ - fun registerUnifiedPushForAllAccounts( - context: Context, - userManager: UserManager, - ncApi: NcApi - ): Observable { - val obs = userManager.users.blockingGet().map { user -> - registerUnifiedPushForAccount(context, ncApi, user) + private fun activateWebPushForAccount( + user: User, + activationToken: String + ) : Observable { + Log.d(TAG, "Activating web push for ${user.userId}") + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return Observable.empty() } - return Observable.merge(obs) - // We do not update the user push proxy setting on error - .flatMap { res -> - val user = res.first - val wasUsingProxyPush = user.usesProxyPush - user.usesProxyPush = !res.second - userManager.saveUser(user) - Log.d(TAG, "User ${user.userId} updated: wasUsingProxy=$wasUsingProxyPush, now=${user.usesProxyPush}") - if (wasUsingProxyPush && !user.usesProxyPush) { - Observable.just(user) - } else { - Observable.empty() + return ncApi.activateWebPush( + user.getCredentials(), + ApiUtils.getUrlForWebPushActivation(user.baseUrl!!), + activationToken + ).map { r -> + return@map when (r.code()) { + 200 -> { + Log.d(TAG, "Web push registration for ${user.userId} was already activated\n") + true + } + 202 -> { + Log.d(TAG, "Web push registration for ${user.userId} activated") + true + } + else -> { + Log.d(TAG, "An error occurred while registering web push for ${user.userId} (status=${r.code()})") + false } } + } } /** @@ -200,15 +335,13 @@ class PushRegistrationWorker( * * Web push is registered on the nc server when the push endpoint is received * - * @return `Observable`, true if registration succeed, false if server doesn't support web push + * @return `Observable>`, true if registration succeed, false if server doesn't support web push */ private fun registerUnifiedPushForAccount( - context: Context, - ncApi: NcApi, user: User ): Observable> { if (user.hasWebPushCapability) { - Log.d(TAG, "Registering web push for ${user.userId}") + Log.d(TAG, "Registering UnifiedPush for ${user.userId}") if (user.userId == null || user.baseUrl == null) { Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") return Observable.empty() @@ -217,7 +350,7 @@ class PushRegistrationWorker( .flatMap { ocs -> ocs.ocs?.data?.vapid?.let { vapid -> UnifiedPush.register( - context, + applicationContext, instance = user.userId!!, messageForDistributor = user.userId, vapid = vapid @@ -237,6 +370,9 @@ class PushRegistrationWorker( companion object { const val TAG = "PushRegistrationWorker" const val ORIGIN = "origin" + const val USER_ID = "user_id" + const val ACTIVATION_TOKEN = "activation_token" const val USE_UNIFIEDPUSH = "use_unifiedpush" + const val UNIFIEDPUSH_ENDPOINT = "unifiedpush_endpoint" } } From 4c05acd5f43af4ef42947ce28cce0201b1bf5a9a 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 5f4d0e3a8d..512870d3ff 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -21,6 +21,7 @@ import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.PushUtils +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observable import io.reactivex.schedulers.Schedulers @@ -184,7 +185,7 @@ class PushRegistrationWorker( Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") return@mapNotNull null } - UnifiedPush.unregister(applicationContext, user.userId!!) + UnifiedPush.unregister(applicationContext, UnifiedPushUtils.instanceFor(user)) // TODO unregisterWebPushForUser Observable.empty() } @@ -351,7 +352,7 @@ class PushRegistrationWorker( ocs.ocs?.data?.vapid?.let { vapid -> UnifiedPush.register( applicationContext, - instance = user.userId!!, + instance = UnifiedPushUtils.instanceFor(user), messageForDistributor = user.userId, vapid = vapid ) diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index e1be9b6024..a28e47061c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -13,6 +13,7 @@ import android.util.Log import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.PushRegistrationWorker import org.unifiedpush.android.connector.UnifiedPush @@ -93,4 +94,11 @@ object UnifiedPushUtils { WorkManager.getInstance(context).enqueue(pushRegistrationWork) } + + /** + * Get UnifiedPush instance for user + * + * This is simply the [User.id] (long) in String, but it allows defining it in a single place + */ + fun instanceFor(user: User): String = "${user.id}" } From 7af055f8b76e2405c305edc62307d8ab081a9b12 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 64f59300ad..10899b632f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -336,6 +336,13 @@ android:exported="false" android:foregroundServiceType="microphone|camera" /> + + + + + + + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.services + +import android.util.Log +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.nextcloud.talk.jobs.PushRegistrationWorker +import com.nextcloud.talk.utils.UnifiedPushUtils.toByteArray +import org.json.JSONException +import org.json.JSONObject +import org.unifiedpush.android.connector.FailedReason +import org.unifiedpush.android.connector.PushService +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage + +class UnifiedPushService: PushService() { + override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { + Log.d(TAG, "New endpoint for $instance") + val endpointBA = endpoint.toByteArray() ?: run { + Log.w(TAG, "Couldn't serialize endpoint!") + return + } + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushService#onNewEndpoint") + .putLong(PushRegistrationWorker.USER_ID, instance.toLong()) + .putByteArray(PushRegistrationWorker.UNIFIEDPUSH_ENDPOINT, endpointBA) + .build() + val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(this).enqueue(pushRegistrationWork) + } + + override fun onMessage(message: PushMessage, instance: String) { + Log.d(TAG, "New message for $instance") + try { + val mObj = JSONObject(message.content.toString(Charsets.UTF_8)) + val token = mObj.getString("activationToken") + onActivationToken(token, instance) + } catch (_: JSONException) { + // Messages are encrypted following RFC8291, and UnifiedPush lib handle the decryption itself: + // message.content is the cleartext + } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + Log.d(TAG, "Registration failed for $instance") + } + + override fun onUnregistered(instance: String) { + Log.d(TAG, "$instance unregistered") + } + + private fun onActivationToken(activationToken: String, instance: String) { + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushService#onActivationToken") + .putLong(PushRegistrationWorker.USER_ID, instance.toLong()) + .putString(PushRegistrationWorker.ACTIVATION_TOKEN, activationToken) + .build() + val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(this).enqueue(pushRegistrationWork) + } + + companion object { + const val TAG = "UnifiedPushService" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index a28e47061c..974561d0cd 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.utils import android.app.Activity import android.content.Context +import android.os.Parcel import android.util.Log import androidx.work.Data import androidx.work.OneTimeWorkRequest @@ -16,6 +17,7 @@ import androidx.work.WorkManager import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.PushRegistrationWorker import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint object UnifiedPushUtils { private val TAG: String = UnifiedPushUtils::class.java.getSimpleName() @@ -92,7 +94,6 @@ object UnifiedPushUtils { .setInputData(data) .build() WorkManager.getInstance(context).enqueue(pushRegistrationWork) - } /** @@ -101,4 +102,29 @@ object UnifiedPushUtils { * This is simply the [User.id] (long) in String, but it allows defining it in a single place */ fun instanceFor(user: User): String = "${user.id}" + + fun PushEndpoint.toByteArray(): ByteArray? { + val parcel = Parcel.obtain() + return try { + writeToParcel(parcel, 0) + parcel.marshall() + } catch (_: Exception) { + null + } finally { + parcel.recycle() + } + } + + fun ByteArray.toPushEndpoint(): PushEndpoint? { + val parcel = Parcel.obtain() + return try { + parcel.unmarshall(this, 0, size) + parcel.setDataPosition(0) // Reset Parcel position to read from the start + PushEndpoint.createFromParcel(parcel) + } catch (_: Exception) { + null + } finally { + parcel.recycle() + } + } } From fadb0f9e9964c38e8d2c66176b8431fddd9ff819 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 973b615c8b..302a47875d 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -182,9 +182,12 @@ class PushRegistrationWorker( Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") return@mapNotNull null } - UnifiedPush.unregister(applicationContext, UnifiedPushUtils.instanceFor(user)) - // TODO unregisterWebPushForUser - Observable.empty() + if (user.hasWebPushCapability) { + UnifiedPush.unregister(applicationContext, UnifiedPushUtils.instanceFor(user)) + unregisterWebPushForAccount(user) + } else { + Observable.empty() + } } Observable.merge(obs) .toList() @@ -328,6 +331,21 @@ class PushRegistrationWorker( } } + private fun unregisterWebPushForAccount( + user: User + ) : Observable { + Log.d(TAG, "Unregistering web push for ${user.userId}") + if (user.userId == null || user.baseUrl == null) { + Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") + return Observable.empty() + } + return ncApi.unregisterWebPush( + user.getCredentials(), + ApiUtils.getUrlForWebPush(user.baseUrl!!) + ).map { true } + + } + /** * Register UnifiedPush with the server VAPID key if the server supports web push * From 3c19a4f4906d51413e8de6b125bb6e13b4a6f042 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 fdf8f40479..d894292bd4 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -53,7 +53,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 @@ -139,7 +139,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 @@ -164,12 +164,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) @@ -207,20 +207,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 @@ -308,7 +308,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 callerPersonBuilder = Person.Builder() @@ -354,7 +354,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor sendNotification(pushMessage.timestamp.toInt(), notification) - checkIfCallIsActive(signatureVerification, conversation) + checkIfCallIsActive(conversation) } chatNetworkDataSource?.getRoom(userBeingCalled, roomToken = pushMessage.id!!) @@ -382,10 +382,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( @@ -399,15 +396,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 ) @@ -420,6 +426,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) @@ -438,13 +445,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() ) ) @@ -610,7 +615,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? = "" @@ -641,7 +646,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val activeStatusBarNotification = findNotificationForRoom( context, - signatureVerification.user!!, + user, pushMessage.id!! ) @@ -692,7 +697,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!!) @@ -717,7 +722,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 } @@ -803,12 +808,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!!, @@ -830,7 +835,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) @@ -1019,13 +1024,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) ) @@ -1035,7 +1040,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor credentials, ApiUtils.getUrlForCall( apiVersion, - signatureVerification.user!!.baseUrl!!, + user.baseUrl!!, pushMessage.id!! ) ) @@ -1053,7 +1058,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 @@ -1149,7 +1154,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor intent.flags = getIntentFlags() val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, pushMessage.id) - bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!) + bundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) bundle.putBoolean(KEY_OPENED_VIA_NOTIFICATION, true) intent.putExtras(bundle) return intent diff --git a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt index f042ca9359..7b8766980a 100644 --- a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt @@ -11,8 +11,10 @@ import android.util.Log import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager +import com.nextcloud.talk.jobs.NotificationWorker import com.nextcloud.talk.jobs.PushRegistrationWorker import com.nextcloud.talk.utils.UnifiedPushUtils.toByteArray +import com.nextcloud.talk.utils.bundle.BundleKeys import org.json.JSONException import org.json.JSONObject import org.unifiedpush.android.connector.FailedReason @@ -47,6 +49,14 @@ class UnifiedPushService: PushService() { } catch (_: JSONException) { // Messages are encrypted following RFC8291, and UnifiedPush lib handle the decryption itself: // message.content is the cleartext + val messageData = Data.Builder() + .putLong(BundleKeys.KEY_NOTIFICATION_USER_ID, instance.toLong()) + .putString(BundleKeys.KEY_NOTIFICATION_CLEARTEXT_SUBJECT, message.content.toString(Charsets.UTF_8)) + .build() + val notificationWork = + OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData) + .build() + WorkManager.getInstance(this).enqueue(notificationWork) } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt index 8b12a483f0..e5966dbae4 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 166cf5af607ecb36cb0220df2545a3d896f1fd93 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 a32436fed4..f01201e68a 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 974561d0cd..23fd5832d2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -59,7 +59,7 @@ object UnifiedPushUtils { * @param accountManager: Used to register all accounts * @param callback: run with the push service name if available */ - /*@JvmStatic + @JvmStatic fun pickDistributor( activity: Activity, callback: (String?) -> Unit @@ -67,13 +67,13 @@ object UnifiedPushUtils { Log.d(TAG, "Picking another UnifiedPush distributor") UnifiedPush.tryPickDistributor(activity as Context) { res -> if (res) { - enqueuePushWorker(activity, true, "useDefaultDistributor") + enqueuePushWorker(activity, true, "pickDistributor") callback(UnifiedPush.getSavedDistributor(activity)) } else { callback(null) } } - }*/ + } /** * Disable UnifiedPush and try to register with proxy push again diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 67748cbfa2..f24e82db6e 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 1c81933224854ea3bd5804910f28182b74fefbaa 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 302a47875d..2c5c7157e4 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -38,7 +38,7 @@ import javax.inject.Inject * Can be used for 4 different things: * - if inputData contains [USER_ID] and [ACTIVATION_TOKEN]: activate web push for user (on server) and unregister * for proxy push (on server) (received from [com.nextcloud.talk.services.UnifiedPushService]) - * - if inputData contains [UNIFIEDPUSH_ENDPOINT]: register for web push (on server) + * - if inputData contains [USER_ID] and [UNIFIEDPUSH_ENDPOINT]: register for web push (on server) * (received from [com.nextcloud.talk.services.UnifiedPushService]) * - if inputData contains [USE_UNIFIEDPUSH] or if [AppPreferences.getUseUnifiedPush]: get the server VAPID key and * register for UnifiedPush to the distributor (on device) @@ -89,9 +89,9 @@ class PushRegistrationWorker( if (userId != -1L && activationToken != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushActivationWork)") webPushActivationWork(userId, activationToken) - } else if (pushEndpoint != null) { + } else if (userId != -1L && pushEndpoint != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushWork)") - webPushWork(pushEndpoint) + webPushWork(userId, pushEndpoint) } else if (useUnifiedPush) { Log.d(TAG, "PushRegistrationWorker called via $origin (unifiedPushWork)") unifiedPushWork() @@ -131,11 +131,9 @@ class PushRegistrationWorker( * Register for web push (on server) */ @SuppressLint("CheckResult") - private fun webPushWork(pushEndpoint: PushEndpoint) { - val obs = userManager.users.blockingGet().map { user -> - registerWebPushForAccount(user, pushEndpoint) - } - Observable.merge(obs) + private fun webPushWork(id: Long, pushEndpoint: PushEndpoint) { + val user = userManager.getUserWithId(id).blockingGet() + registerWebPushForAccount(user, pushEndpoint) .map { (user, res) -> if (res) { Log.d(TAG, "User ${user.userId} registered for web push.") From c79168e700bc4842812eb511243323c1df414be0 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 2c5c7157e4..340a14fe72 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -109,7 +109,7 @@ class PushRegistrationWorker( private fun webPushActivationWork(id: Long, activationToken: String) { val user = userManager.getUserWithId(id).blockingGet() activateWebPushForAccount(user, activationToken) - .map { res -> + .flatMap { res -> if (res) { unregisterProxyPush(user) } else { From 9525b3b55f9e9d69c8076ad71e3b38c7be972e18 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 340a14fe72..03674f8e39 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -121,8 +121,7 @@ class PushRegistrationWorker( .subscribeOn(Schedulers.io()) .subscribe { _, e -> e?.let { - Log.d(TAG, "An error occurred while activating web push, or unregistering proxy push") - e.printStackTrace() + Log.e(TAG, "An error occurred while activating web push, or unregistering proxy push", e) } } } @@ -144,8 +143,7 @@ class PushRegistrationWorker( .subscribeOn(Schedulers.io()) .subscribe { _, e -> e?.let { - Log.d(TAG, "An error occurred while registering for web push") - e.printStackTrace() + Log.e(TAG, "An error occurred while registering for web push", e) } } } @@ -163,8 +161,7 @@ class PushRegistrationWorker( .subscribeOn(Schedulers.io()) .subscribe { _, e -> e?.let { - Log.d(TAG, "An error occurred while registering for UnifiedPush") - e.printStackTrace() + Log.e(TAG, "An error occurred while registering for UnifiedPush", e) } } } @@ -192,8 +189,7 @@ class PushRegistrationWorker( .subscribeOn(Schedulers.io()) .subscribe { _, e -> e?.let { - Log.d(TAG, "An error occurred while unregistering for web push") - e.printStackTrace() + Log.e(TAG, "An error occurred while unregistering for web push", e) } // Register proxy push for all account, no matter the result of the web push unregistration registerProxyPush() From e2d5326a9be2edf3e618f504917a61a1540bd1be 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 03674f8e39..a504caa36c 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -31,7 +31,6 @@ import okhttp3.OkHttpClient import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.data.PushEndpoint import retrofit2.Retrofit -import java.net.CookieManager import javax.inject.Inject /** diff --git a/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt index 95e59b2148..bf0d53aeaa 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt @@ -221,6 +221,7 @@ class PushUtils { user: User ) { val credentials = ApiUtils.getCredentials(user.username, user.token) + Log.d(TAG, "Registering proxy push with ${user.userId}'s server.") ncApi.registerDeviceForNotificationsWithNextcloud( credentials, ApiUtils.getUrlNextcloudPush(user.baseUrl!!), From 610fd9b714c7f936093b4e5abf23a21647f38bd5 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 f600c227ad..8ed244f24d 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -264,13 +264,7 @@ class AccountVerificationActivity : BaseActivity() { ClosedInterfaceImpl().setUpPushTokenRegistration() } else { Log.w(TAG, "Skipping push registration.") - runOnUiThread { - binding.progressText.text = - """ ${binding.progressText.text} - ${resources!!.getString(R.string.nc_push_disabled)} - """.trimIndent() - } - fetchAndStoreCapabilities() + eventBus.post(EventStatus(user.id!!, EventStatus.EventType.PUSH_REGISTRATION, false)) } } From f53fdb5278b68ad67615b40caa1c33c8b34a0971 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 8ed244f24d..6d0417e7f6 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -340,41 +340,47 @@ class AccountVerificationActivity : BaseActivity() { @Subscribe(threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(eventStatus: EventStatus) { Log.d(TAG, "caught EventStatus of type " + eventStatus.eventType.toString()) - if (eventStatus.eventType == EventStatus.EventType.PUSH_REGISTRATION) { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { - runOnUiThread { - binding.progressText.text = - """ + // We do PUSH_REGISTRATION -> CAPABILITIES_FETCH -> SIGNALING_SETTINGS + when (eventStatus.eventType) { + EventStatus.EventType.PUSH_REGISTRATION -> { + if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + runOnUiThread { + binding.progressText.text = + """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_push_disabled)} """.trimIndent() + } } + fetchAndStoreCapabilities() } - fetchAndStoreCapabilities() - } else if (eventStatus.eventType == EventStatus.EventType.CAPABILITIES_FETCH) { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { - runOnUiThread { - binding.progressText.text = - """ + EventStatus.EventType.CAPABILITIES_FETCH -> { + if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + runOnUiThread { + binding.progressText.text = + """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_capabilities_failed)} """.trimIndent() + } + abortVerification() + } else if (internalAccountId == eventStatus.userId && eventStatus.isAllGood) { + fetchAndStoreExternalSignalingSettings() } - abortVerification() - } else if (internalAccountId == eventStatus.userId && eventStatus.isAllGood) { - fetchAndStoreExternalSignalingSettings() } - } else if (eventStatus.eventType == EventStatus.EventType.SIGNALING_SETTINGS) { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { - runOnUiThread { - binding.progressText.text = - """ + EventStatus.EventType.SIGNALING_SETTINGS -> { + if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + runOnUiThread { + binding.progressText.text = + """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_external_server_failed)} """.trimIndent() + } } + proceedWithLogin() } - proceedWithLogin() + else -> {} } } From fd79961d8b829cc9d20deb22e6dd992b349f872f 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 6d0417e7f6..b9e248fcd2 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -340,10 +340,14 @@ class AccountVerificationActivity : BaseActivity() { @Subscribe(threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(eventStatus: EventStatus) { Log.d(TAG, "caught EventStatus of type " + eventStatus.eventType.toString()) + if (internalAccountId != eventStatus.userId) { + Log.d(TAG, "Event isn't for us. Aborting.") + return + } // We do PUSH_REGISTRATION -> CAPABILITIES_FETCH -> SIGNALING_SETTINGS when (eventStatus.eventType) { EventStatus.EventType.PUSH_REGISTRATION -> { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ @@ -355,7 +359,7 @@ class AccountVerificationActivity : BaseActivity() { fetchAndStoreCapabilities() } EventStatus.EventType.CAPABILITIES_FETCH -> { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ @@ -364,12 +368,12 @@ class AccountVerificationActivity : BaseActivity() { """.trimIndent() } abortVerification() - } else if (internalAccountId == eventStatus.userId && eventStatus.isAllGood) { + } else { fetchAndStoreExternalSignalingSettings() } } EventStatus.EventType.SIGNALING_SETTINGS -> { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { + if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ From 21cb3426a320bc3cc2b4055e5df2b9e26c2aee99 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 b9e248fcd2..ed06ba115e 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -260,12 +260,7 @@ class AccountVerificationActivity : BaseActivity() { @SuppressLint("SetTextI18n") override fun onSuccess(user: User) { internalAccountId = user.id!! - if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { - ClosedInterfaceImpl().setUpPushTokenRegistration() - } else { - Log.w(TAG, "Skipping push registration.") - eventBus.post(EventStatus(user.id!!, EventStatus.EventType.PUSH_REGISTRATION, false)) - } + eventBus.post(EventStatus(user.id!!, EventStatus.EventType.PROFILE_STORED, true)) } @SuppressLint("SetTextI18n") @@ -344,8 +339,19 @@ class AccountVerificationActivity : BaseActivity() { Log.d(TAG, "Event isn't for us. Aborting.") return } - // We do PUSH_REGISTRATION -> CAPABILITIES_FETCH -> SIGNALING_SETTINGS + // We do: PROFILE_STORED + // -> PUSH_REGISTRATION + // -> CAPABILITIES_FETCH + // -> SIGNALING_SETTINGS when (eventStatus.eventType) { + EventStatus.EventType.PROFILE_STORED -> { + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + ClosedInterfaceImpl().setUpPushTokenRegistration() + } else { + Log.w(TAG, "Skipping push registration.") + eventBus.post(EventStatus(eventStatus.userId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } EventStatus.EventType.PUSH_REGISTRATION -> { if (!eventStatus.isAllGood) { runOnUiThread { diff --git a/app/src/main/java/com/nextcloud/talk/events/EventStatus.java b/app/src/main/java/com/nextcloud/talk/events/EventStatus.java index c8470903b9..f8e1af6b16 100644 --- a/app/src/main/java/com/nextcloud/talk/events/EventStatus.java +++ b/app/src/main/java/com/nextcloud/talk/events/EventStatus.java @@ -84,7 +84,8 @@ public String toString() { } public enum EventType { - PUSH_REGISTRATION, CAPABILITIES_FETCH, SIGNALING_SETTINGS, CONVERSATION_UPDATE, PARTICIPANTS_UPDATE + PROFILE_STORED, PUSH_REGISTRATION, CAPABILITIES_FETCH, SIGNALING_SETTINGS, CONVERSATION_UPDATE, + PARTICIPANTS_UPDATE } } From d691d6b2ddb337f5a8fd2985d89476807082c021 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 ed06ba115e..bd1ec31ddb 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -340,43 +340,38 @@ class AccountVerificationActivity : BaseActivity() { return } // We do: PROFILE_STORED - // -> PUSH_REGISTRATION // -> CAPABILITIES_FETCH + // -> PUSH_REGISTRATION // -> SIGNALING_SETTINGS when (eventStatus.eventType) { EventStatus.EventType.PROFILE_STORED -> { - if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { - ClosedInterfaceImpl().setUpPushTokenRegistration() - } else { - Log.w(TAG, "Skipping push registration.") - eventBus.post(EventStatus(eventStatus.userId, EventStatus.EventType.PUSH_REGISTRATION, false)) - } + fetchAndStoreCapabilities() } - EventStatus.EventType.PUSH_REGISTRATION -> { + EventStatus.EventType.CAPABILITIES_FETCH -> { if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ ${binding.progressText.text} - ${resources!!.getString(R.string.nc_push_disabled)} + ${resources!!.getString(R.string.nc_capabilities_failed)} """.trimIndent() } + abortVerification() + } else { + setupPushNotifications() } - fetchAndStoreCapabilities() } - EventStatus.EventType.CAPABILITIES_FETCH -> { + EventStatus.EventType.PUSH_REGISTRATION -> { if (!eventStatus.isAllGood) { runOnUiThread { binding.progressText.text = """ ${binding.progressText.text} - ${resources!!.getString(R.string.nc_capabilities_failed)} + ${resources!!.getString(R.string.nc_push_disabled)} """.trimIndent() } - abortVerification() - } else { - fetchAndStoreExternalSignalingSettings() } + fetchAndStoreExternalSignalingSettings() } EventStatus.EventType.SIGNALING_SETTINGS -> { if (!eventStatus.isAllGood) { @@ -394,6 +389,15 @@ class AccountVerificationActivity : BaseActivity() { } } + private fun setupPushNotifications() { + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + ClosedInterfaceImpl().setUpPushTokenRegistration() + } else { + Log.w(TAG, "Skipping push registration.") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } + private fun fetchAndStoreCapabilities() { val userData = Data.Builder() From 8cb5e5395c93ec2fae46fb84fde4fd64593fd0c2 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 bd1ec31ddb..8486cc51d6 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -42,6 +42,7 @@ import com.nextcloud.talk.models.json.userprofile.UserProfileOverall import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.UriUtils import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BASE_URL import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID @@ -58,6 +59,7 @@ import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.unifiedpush.android.connector.UnifiedPush import java.net.CookieManager import javax.inject.Inject @@ -390,8 +392,34 @@ class AccountVerificationActivity : BaseActivity() { } private fun setupPushNotifications() { - if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + // This isn't a first account, and UnifiedPush is enabled. + if (appPreferences.useUnifiedPush) { + if (userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { + UnifiedPushUtils.registerWithCurrentDistributor( + context + ) + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + } else { + Log.w(TAG, "Warning: disabling UnifiedPush, user server doesn't support web push.") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + // This may or may not be a first account, use Play Services if available + } else if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { ClosedInterfaceImpl().setUpPushTokenRegistration() + // This is a first user, we have a UnifiedPush distributor, + // and the server supports web push + } else if (userManager.users.blockingGet().size == 1 && + UnifiedPush.getDistributors(context).isNotEmpty() && + userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { + UnifiedPushUtils.useDefaultDistributor(this) { distrib -> + distrib?.let { + Log.d(TAG, "UnifiedPush registered with $distrib") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + } ?: run { + Log.d(TAG, "No UnifiedPush distrib selected") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } } else { Log.w(TAG, "Skipping push registration.") eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index 23fd5832d2..90ddafe3f2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -49,6 +49,11 @@ object UnifiedPushUtils { } } + @JvmStatic + fun registerWithCurrentDistributor(context: Context) { + enqueuePushWorker(context, true, "registerWithCurrentDistributor") + } + /** * Pick another distributor, register all accounts that support webpush * From 6e09f43ba54c0db9329d9e47894e05f3133040c6 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 4c5c6b50dc..d42d82c8bb 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -38,6 +38,7 @@ import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.SecurityUtils +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import io.reactivex.Observer @@ -260,7 +261,11 @@ class MainActivity : override fun onSuccess(users: List) { if (users.isNotEmpty()) { - ClosedInterfaceImpl().setUpPushTokenRegistration() + if (appPreferences.useUnifiedPush) { + UnifiedPushUtils.setPeriodicPushRegistrationWorker(this@MainActivity) + } else { + ClosedInterfaceImpl().setUpPushTokenRegistration() + } runOnUiThread { openConversationList() } diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index 90ddafe3f2..047fa3da70 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -12,15 +12,20 @@ import android.content.Context import android.os.Parcel import android.util.Log import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.OneTimeWorkRequest +import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.PushRegistrationWorker import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.data.PushEndpoint +import java.util.concurrent.TimeUnit object UnifiedPushUtils { private val TAG: String = UnifiedPushUtils::class.java.getSimpleName() + const val DAILY: Long = 24 + const val FLEX_INTERVAL: Long = 10 /** * Use default distributor, register all accounts that support webpush @@ -101,6 +106,33 @@ object UnifiedPushUtils { WorkManager.getInstance(context).enqueue(pushRegistrationWork) } + /** + * Call only if [com.nextcloud.talk.utils.preferences.AppPreferences.getUseUnifiedPush], + * else [ClosedInterfaceImpl.setUpPushTokenRegistration] is called and does the same as + * this function + */ + fun setPeriodicPushRegistrationWorker(context: Context) { + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushUtils#setPeriodicPushRegistrationWorker") + .build() + val periodicPushRegistrationWork = PeriodicWorkRequest.Builder( + PushRegistrationWorker::class.java, + DAILY, + TimeUnit.HOURS, + FLEX_INTERVAL, + TimeUnit.HOURS + ) + .setInputData(data) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + "periodicPushRegistrationWorker", + ExistingPeriodicWorkPolicy.UPDATE, + periodicPushRegistrationWork + ) + } + /** * Get UnifiedPush instance for user * From af52d277b71b85c604c037f5392ef5e6af26d15f 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 8486cc51d6..bc2e7b0788 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -402,6 +402,7 @@ class AccountVerificationActivity : BaseActivity() { } else { Log.w(TAG, "Warning: disabling UnifiedPush, user server doesn't support web push.") eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + appPreferences.useUnifiedPush = false } // This may or may not be a first account, use Play Services if available } else if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { From 60d737407a39dc2a1f70a1a368c7d5cd84e4cf3c 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 bc2e7b0788..7f9f7c1bd2 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -395,20 +395,21 @@ class AccountVerificationActivity : BaseActivity() { // This isn't a first account, and UnifiedPush is enabled. if (appPreferences.useUnifiedPush) { if (userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { - UnifiedPushUtils.registerWithCurrentDistributor( - context - ) + UnifiedPushUtils.registerWithCurrentDistributor(context) eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + return } else { Log.w(TAG, "Warning: disabling UnifiedPush, user server doesn't support web push.") - eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) appPreferences.useUnifiedPush = false } - // This may or may not be a first account, use Play Services if available - } else if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + } + + // - By default, use the Play Services if available + // - If this is a first user, and we have an External UnifiedPush distributor, + // and the server supports it: we use it + // - Else we skip push registrations + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { ClosedInterfaceImpl().setUpPushTokenRegistration() - // This is a first user, we have a UnifiedPush distributor, - // and the server supports web push } else if (userManager.users.blockingGet().size == 1 && UnifiedPush.getDistributors(context).isNotEmpty() && userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { From d358122653a5bfd36146b8dfb2023a575a55003a 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 7f9f7c1bd2..5ce1302925 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -416,6 +416,7 @@ class AccountVerificationActivity : BaseActivity() { UnifiedPushUtils.useDefaultDistributor(this) { distrib -> distrib?.let { Log.d(TAG, "UnifiedPush registered with $distrib") + appPreferences.useUnifiedPush = true eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) } ?: run { Log.d(TAG, "No UnifiedPush distrib selected") From 6cf439f040496e0f2b1e7dacbcd9869f2d318be7 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 f01201e68a..82840f8927 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 9d657b34020e5f9edccd274e87bee652f515d809 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 e5d3eb4d47..bfeba3c688 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -305,7 +305,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 7345e5957df23346b7b054043bc7c40ffc5b8e24 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 3ac34cbf9d..e773903c20 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 a504caa36c..47d91a5c42 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -130,6 +130,7 @@ class PushRegistrationWorker( */ @SuppressLint("CheckResult") private fun webPushWork(id: Long, pushEndpoint: PushEndpoint) { + preferences.unifiedPushLatestEndpoint = System.currentTimeMillis() val user = userManager.getUserWithId(id).blockingGet() registerWebPushForAccount(user, pushEndpoint) .map { (user, res) -> diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index 797b5bef32..f1d8476c1b 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 f92c3f0dba..dfb2e266ba 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 e086bd9913..fbe0b31bfd 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 a0005374d614c8f62e642739ac947f2c5bf059dc 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 7b8766980a..30a9219c94 100644 --- a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt @@ -61,7 +61,8 @@ class UnifiedPushService: PushService() { } override fun onRegistrationFailed(reason: FailedReason, instance: String) { - Log.d(TAG, "Registration failed for $instance") + Log.w(TAG, "Registration failed for $instance, reason=$reason") + // Do nothing, we let the periodic worker try to re-register later } override fun onUnregistered(instance: String) { From dc502597cec71b59505c4fd114ff536242ebd3ee 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 47d91a5c42..f3a348c8ad 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -11,6 +11,9 @@ package com.nextcloud.talk.jobs import android.annotation.SuppressLint import android.content.Context import android.util.Log +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters import autodagger.AutoInjector @@ -23,9 +26,11 @@ import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.PushUtils import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.UnifiedPushUtils.toPushEndpoint +import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observable import io.reactivex.schedulers.Schedulers +import kotlinx.serialization.json.Json import okhttp3.CookieJar import okhttp3.OkHttpClient import org.unifiedpush.android.connector.UnifiedPush @@ -84,6 +89,7 @@ class PushRegistrationWorker( val userId = inputData.getLong(USER_ID, -1) val activationToken = inputData.getString(ACTIVATION_TOKEN) val pushEndpoint = inputData.getByteArray(UNIFIEDPUSH_ENDPOINT)?.toPushEndpoint() + val unregisterWebPush = inputData.getBoolean(UNREGISTER_WEBPUSH, false) val useUnifiedPush = inputData.getBoolean(USE_UNIFIEDPUSH, defaultUseUnifiedPush()) if (userId != -1L && activationToken != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushActivationWork)") @@ -91,6 +97,9 @@ class PushRegistrationWorker( } else if (userId != -1L && pushEndpoint != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushWork)") webPushWork(userId, pushEndpoint) + } else if (userId != -1L && unregisterWebPush) { + Log.d(TAG, "PushRegistrationWorker called via $origin (webPushUnregistrationWork)") + webPushUnregistrationWork(userId) } else if (useUnifiedPush) { Log.d(TAG, "PushRegistrationWorker called via $origin (unifiedPushWork)") unifiedPushWork() @@ -148,6 +157,27 @@ class PushRegistrationWorker( } } + /** + * Unregister web push for user + * + * Disable UnifiedPush if we don't have a distributor anymore + */ + @SuppressLint("CheckResult") + private fun webPushUnregistrationWork(id: Long) { + userManager.getUserWithId(id).map { user -> + unregisterWebPushForAccount(user) + .toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + Log.e(TAG, "An error occurred while unregistering for web push", e) + } ?: { + Log.d(TAG, "${user.userId} unregistered from web push") + } + } + } + } + /** * Get VAPID key (on server) and register UnifiedPush to the distributor (on device) */ @@ -205,9 +235,30 @@ class PushRegistrationWorker( if (it == null) { Log.d(TAG, "No saved distributor found: disabling UnifiedPush") preferences.useUnifiedPush = false + if (inputData.keyValueMap.any { (key, _) -> + RESTART_ON_DISTRIB_UNINSTALL.contains(key) + }) { + enqueueWorkerWithoutData("defaultUseDistributor") + } } } != null + /** + * Run the default worker, to use FCM if available + * when the distributor has been uninstalled + */ + private fun enqueueWorkerWithoutData(origin: String) { + // Run the default worker, to use FCM if available + val data = Data.Builder() + .putString(ORIGIN, "PushRegistrationWorker#$origin") + .build() + val periodicPushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(applicationContext) + .enqueue(periodicPushRegistrationWork) + } + /** * Register proxy push for all accounts if the devices support the Play Services * @@ -384,5 +435,17 @@ class PushRegistrationWorker( const val ACTIVATION_TOKEN = "activation_token" const val USE_UNIFIEDPUSH = "use_unifiedpush" const val UNIFIEDPUSH_ENDPOINT = "unifiedpush_endpoint" + const val UNREGISTER_WEBPUSH = "unregister_webpush" + + /** + * If any of these actions are present when we observe the distributor is uninstalled, + * we enqueue a worker with default settings, to fallback to FCM if needed + */ + private val RESTART_ON_DISTRIB_UNINSTALL = listOf( + ACTIVATION_TOKEN, + USE_UNIFIEDPUSH, + UNIFIEDPUSH_ENDPOINT, + UNREGISTER_WEBPUSH + ) } } diff --git a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt index 30a9219c94..b21829b2b7 100644 --- a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt @@ -67,6 +67,15 @@ class UnifiedPushService: PushService() { override fun onUnregistered(instance: String) { Log.d(TAG, "$instance unregistered") + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushService#onUnregistered") + .putBoolean(PushRegistrationWorker.UNREGISTER_WEBPUSH, true) + .putLong(PushRegistrationWorker.USER_ID, instance.toLong()) + .build() + val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + WorkManager.getInstance(this).enqueue(pushRegistrationWork) } private fun onActivationToken(activationToken: String, instance: String) { From 4a1807b721fe54936589e53a89e3636f05224c7b 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 d894292bd4..a24390bf2b 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -182,9 +182,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.") } @@ -192,7 +194,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) @@ -441,6 +443,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 @@ -644,11 +647,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. @@ -1184,6 +1189,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor private const val TYPE_REMINDER = "reminder" private const val TYPE_ADMIN_NOTIFICATIONS = "admin_notifications" private const val SPREED_APP = "spreed" + private const val INTERNAL = "internal" private const val ADMIN_NOTIFICATION_TALK = "admin_notification_talk" private const val TIMER_START = 1 private const val TIMER_COUNT = 12 diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index f3a348c8ad..29df873fb5 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -239,6 +239,7 @@ class PushRegistrationWorker( RESTART_ON_DISTRIB_UNINSTALL.contains(key) }) { enqueueWorkerWithoutData("defaultUseDistributor") + enqueueNotifUnifiedPushDisabled() } } } != null @@ -259,6 +260,29 @@ class PushRegistrationWorker( .enqueue(periodicPushRegistrationWork) } + /** + * Show a notification to the user to inform UnifiedPush has been disabled + */ + @SuppressLint("CheckResult") + private fun enqueueNotifUnifiedPushDisabled() { + val user = userManager.users.blockingGet().first() + Log.d(TAG, "Sending warning notification with ${user.userId}") + val notif = hashMapOf( + "subject" to "UnifiedPush disabled", + "text" to "You have been unregistered from the distributor. Re-enable in the settings if needed", + "app" to "internal", + "type" to "admin_notifications" + ) + val messageData = Data.Builder() + .putLong(BundleKeys.KEY_NOTIFICATION_USER_ID, user.id!!) + .putString(BundleKeys.KEY_NOTIFICATION_CLEARTEXT_SUBJECT, Json.encodeToString(notif)) + .build() + val notificationWork = + OneTimeWorkRequest.Builder(NotificationWorker::class.java).setInputData(messageData) + .build() + WorkManager.getInstance(applicationContext).enqueue(notificationWork) + } + /** * Register proxy push for all accounts if the devices support the Play Services * diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt index e3bb3cc74e..6b519de312 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt @@ -46,7 +46,11 @@ data class DecryptedPushMessage( @JsonIgnore var notificationUser: NotificationUser?, - @JsonIgnore + /** + * /!\ It is overridden by common NC notifications, just used + * for internal notifications + */ + @JsonField(name = ["text"]) var text: String?, @JsonIgnore From 3bdd26bc2f1a3526acdf4a3456240ceb2024d383 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 29df873fb5..ebcb531afc 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -232,6 +232,8 @@ class PushRegistrationWorker( // => we can't be acked by the distributor yet, [UnifiedPush.getAckDistributor] == null // So we check the SavedDistributor instead UnifiedPush.getSavedDistributor(applicationContext).also { + // It is null if the distributor has unregistered all the accounts, + // or if it has been uninstalled from the system if (it == null) { Log.d(TAG, "No saved distributor found: disabling UnifiedPush") preferences.useUnifiedPush = false From 77346336660a77e73176ad0f37957ebeb2f4592a 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 5ce1302925..d81d6b2ea5 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -39,6 +39,7 @@ import com.nextcloud.talk.jobs.WebsocketConnectionsWorker import com.nextcloud.talk.models.json.capabilities.CapabilitiesOverall import com.nextcloud.talk.models.json.generic.Status import com.nextcloud.talk.models.json.userprofile.UserProfileOverall +import com.nextcloud.talk.ui.dialog.IntroduceUnifiedPushDialog import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl @@ -410,25 +411,58 @@ class AccountVerificationActivity : BaseActivity() { // - Else we skip push registrations if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { ClosedInterfaceImpl().setUpPushTokenRegistration() + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) } else if (userManager.users.blockingGet().size == 1 && UnifiedPush.getDistributors(context).isNotEmpty() && userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { - UnifiedPushUtils.useDefaultDistributor(this) { distrib -> - distrib?.let { - Log.d(TAG, "UnifiedPush registered with $distrib") - appPreferences.useUnifiedPush = true - eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) - } ?: run { - Log.d(TAG, "No UnifiedPush distrib selected") - eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) - } - } + useUnifiedPushIntroduced() } else { Log.w(TAG, "Skipping push registration.") eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) } } + /** + * Show a dialog if the user has to select their distributor + * + * Most of the time, nothing will be shown, as most users have + * a single distributor, or already selected their default one + */ + private fun useUnifiedPushIntroduced() { + if (UnifiedPushUtils.usingDefaultDistributorNeedsIntro(context)) { + dialogForUnifiedPush { res -> + if (res) { + useUnifiedPush() + } + } + } else { + useUnifiedPush() + } + } + + private fun useUnifiedPush() { + UnifiedPushUtils.useDefaultDistributor(this) { distrib -> + distrib?.let { + Log.d(TAG, "UnifiedPush registered with $distrib") + appPreferences.useUnifiedPush = true + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + } ?: run { + Log.d(TAG, "No UnifiedPush distrib selected") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } + } + + private fun dialogForUnifiedPush(onResponse: (Boolean) -> Unit) { + binding.genericComposeView.apply { + setContent { + IntroduceUnifiedPushDialog { res -> + onResponse(res) + } + } + } + } + private fun fetchAndStoreCapabilities() { val userData = Data.Builder() diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt new file mode 100644 index 0000000000..25648eb508 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.dialog; + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable; +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.nextcloud.talk.R + +@Composable +fun IntroduceUnifiedPushDialog( + onResponse: (Boolean) -> Unit +) { + var showDialog by remember { mutableStateOf(true) } + if (showDialog) { + AlertDialog( + confirmButton = { + TextButton(onClick = { + onResponse(true) + showDialog = false + }) { + Text(stringResource(android.R.string.ok)) + } + }, + onDismissRequest = { + onResponse(false) + showDialog = false + }, + dismissButton = { + TextButton(onClick = { + onResponse(false) + showDialog = false + }) { + Text(stringResource(android.R.string.cancel)) + } + }, + title = { + Text(stringResource(R.string.unifiedpush)) + }, + text = { + Text(stringResource(R.string.nc_dialog_introduce_unifiedpush_selection)) + }, + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index 047fa3da70..f43211b0b8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -20,6 +20,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.PushRegistrationWorker import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.ResolvedDistributor import java.util.concurrent.TimeUnit object UnifiedPushUtils { @@ -44,7 +45,7 @@ object UnifiedPushUtils { callback: (String?) -> Unit ) { Log.d(TAG, "Using default UnifiedPush distributor") - UnifiedPush.tryUseCurrentOrDefaultDistributor(activity as Context) { res -> + UnifiedPush.tryUseDefaultDistributor(activity) { res -> if (res) { enqueuePushWorker(activity, true, "useDefaultDistributor") callback(UnifiedPush.getSavedDistributor(activity)) @@ -54,6 +55,14 @@ object UnifiedPushUtils { } } + /** + * Does [useDefaultDistributor] show an OS screen to ask the user + * to pick a distributor ? + */ + @JvmStatic + fun usingDefaultDistributorNeedsIntro(context: Context): Boolean = + UnifiedPush.resolveDefaultDistributor(context) == ResolvedDistributor.ToSelect + @JvmStatic fun registerWithCurrentDistributor(context: Context) { enqueuePushWorker(context, true, "registerWithCurrentDistributor") diff --git a/app/src/main/res/layout/activity_account_verification.xml b/app/src/main/res/layout/activity_account_verification.xml index 4e64db5ed1..8e3808b927 100644 --- a/app/src/main/res/layout/activity_account_verification.xml +++ b/app/src/main/res/layout/activity_account_verification.xml @@ -43,4 +43,9 @@ android:textColor="@color/fg_default" tools:text="Verifying..." /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fbe0b31bfd..59d8b2f9e4 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 afc61e7530f2bf0910f08ccef56f924df6ec5bcc 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 ebcb531afc..c8ab1fcd51 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -90,6 +90,8 @@ class PushRegistrationWorker( val activationToken = inputData.getString(ACTIVATION_TOKEN) val pushEndpoint = inputData.getByteArray(UNIFIEDPUSH_ENDPOINT)?.toPushEndpoint() val unregisterWebPush = inputData.getBoolean(UNREGISTER_WEBPUSH, false) + // We always check current status of unifiedpush with defaultUseUnifiedPush here + // If the current distributor is removed, a notification to inform the user is shown val useUnifiedPush = inputData.getBoolean(USE_UNIFIEDPUSH, defaultUseUnifiedPush()) if (userId != -1L && activationToken != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushActivationWork)") From 5748ee9023380d4e6f042db107dc5269bc858df2 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 f43211b0b8..f84f457e63 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -101,6 +101,7 @@ object UnifiedPushUtils { fun disableExternalUnifiedPush( context: Context ) { + UnifiedPush.unregister(context) enqueuePushWorker(context, false, "disableExternalUnifiedPush") } From 7c6a1e3c34e91f1976b66a64fb2c8d16a2b12177 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 c8ab1fcd51..20a74cb947 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -430,7 +430,7 @@ class PushRegistrationWorker( user: User ): Observable> { if (user.hasWebPushCapability) { - Log.d(TAG, "Registering UnifiedPush for ${user.userId}") + Log.d(TAG, "Registering UnifiedPush for ${user.userId} (${user.id})") if (user.userId == null || user.baseUrl == null) { Log.w(TAG, "Null userId or baseUrl (userId=${user.userId}, baseUrl=${user.baseUrl}") return Observable.empty() From 1c51b3fd73efe1f8558cfbc58647fcfa3a21596b 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 a24390bf2b..bc2246a14b 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -574,7 +574,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 3d75dc3e559a44510c536b93ff6954ed6e802b0c 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 f24e82db6e..b0fe73a0ff 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 a885736d2431e04f7088658fb469a43501239c14 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 30 Apr 2026 08:30:41 +0200 Subject: [PATCH 45/47] Add comment for the protobuf-java dependency resolution Signed-off-by: sim --- app/build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 13726ccedd..c8c34c34ad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -177,6 +177,13 @@ configurations.configureEach { exclude(group = "com.google.firebase", module = "firebase-analytics") exclude(group = "com.google.firebase", module = "firebase-measurement-connector") exclude(group = "org.jetbrains", module = "annotations-java5") // via prism4j, already using annotations explicitly + + // com.google.crypto.tink (pulled in by org.unifiedpush.android:connector) transitively depends on + // protobuf-java, which conflicts with protobuf-javalite used by com.google.mediapipe:tasks-core, + // pulled by com.google.mediapipe:tasks-vision + // + // To analyse the dependencies: + // `./gradlew :app:dependencyInsight --configuration genericDebugRuntimeClasspath --dependency com.google.protobuf:protobuf-java` val protobufJava = "com.google.protobuf:protobuf-java:4.28.2" resolutionStrategy { force(protobufJava) From cf44ea1bb87a7e72f4fe75ade8f1d2c4d0946b93 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 30 Apr 2026 19:45:25 +0200 Subject: [PATCH 46/47] format code + cleanup Signed-off-by: Marcel Hibbe --- .../account/AccountVerificationActivity.kt | 9 +- .../ConversationsListActivity.kt | 6 +- .../talk/jobs/PushRegistrationWorker.kt | 96 +++++++++---------- .../talk/services/UnifiedPushService.kt | 4 +- .../talk/settings/SettingsActivity.kt | 5 +- .../ui/dialog/IntroduceUnifiedPushDialog.kt | 12 +-- .../java/com/nextcloud/talk/utils/ApiUtils.kt | 3 + .../com/nextcloud/talk/utils/PushUtils.kt | 1 + .../nextcloud/talk/utils/UnifiedPushUtils.kt | 16 +--- app/src/main/res/values/strings.xml | 3 +- 10 files changed, 72 insertions(+), 83 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 d81d6b2ea5..af94dd324e 100644 --- a/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/account/AccountVerificationActivity.kt @@ -357,7 +357,7 @@ class AccountVerificationActivity : BaseActivity() { """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_capabilities_failed)} - """.trimIndent() + """.trimIndent() } abortVerification() } else { @@ -371,7 +371,7 @@ class AccountVerificationActivity : BaseActivity() { """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_push_disabled)} - """.trimIndent() + """.trimIndent() } } fetchAndStoreExternalSignalingSettings() @@ -383,7 +383,7 @@ class AccountVerificationActivity : BaseActivity() { """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_external_server_failed)} - """.trimIndent() + """.trimIndent() } } proceedWithLogin() @@ -414,7 +414,8 @@ class AccountVerificationActivity : BaseActivity() { 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) { + userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability + ) { useUnifiedPushIntroduced() } else { Log.w(TAG, "Skipping push registration.") 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 bfeba3c688..80663cdd1b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -305,8 +305,10 @@ class ConversationsListActivity : BaseActivity() { // handle notification permission on API level >= 33 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !platformPermissionUtil.isPostNotificationsPermissionGranted() && - (ClosedInterfaceImpl().isGooglePlayServicesAvailable || - appPreferences.useUnifiedPush) + ( + ClosedInterfaceImpl().isGooglePlayServicesAvailable || + appPreferences.useUnifiedPush + ) ) { requestPermissions( arrayOf(Manifest.permission.POST_NOTIFICATIONS), diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt index 20a74cb947..e5d5c19f31 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -1,9 +1,7 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2022 Andy Scherzinger - * SPDX-FileCopyrightText: 2022 Marcel Hibbe - * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package com.nextcloud.talk.jobs @@ -39,21 +37,20 @@ import retrofit2.Retrofit import javax.inject.Inject /** - * Can be used for 4 different things: + * Can be used for 5 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 [USER_ID] and [UNIFIEDPUSH_ENDPOINT]: register for web push (on server) * (received from [com.nextcloud.talk.services.UnifiedPushService]) + * - if inputData contains [USER_ID] and [UNREGISTER_WEBPUSH]: unregister web push for user (on server) * - 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, - workerParams: WorkerParameters -): Worker(context, workerParams) { +@Suppress("TooManyFunctions") +class PushRegistrationWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) { @Inject lateinit var retrofit: Retrofit @@ -96,7 +93,7 @@ class PushRegistrationWorker( if (userId != -1L && activationToken != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushActivationWork)") webPushActivationWork(userId, activationToken) - } else if (userId != -1L && pushEndpoint != null) { + } else if (userId != -1L && pushEndpoint != null) { Log.d(TAG, "PushRegistrationWorker called via $origin (webPushWork)") webPushWork(userId, pushEndpoint) } else if (userId != -1L && unregisterWebPush) { @@ -228,25 +225,27 @@ class PushRegistrationWorker( } } - 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 { - // 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 - if (inputData.keyValueMap.any { (key, _) -> - RESTART_ON_DISTRIB_UNINSTALL.contains(key) - }) { - enqueueWorkerWithoutData("defaultUseDistributor") - enqueueNotifUnifiedPushDisabled() + 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 { + // 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 + if (inputData.keyValueMap.any { (key, _) -> + RESTART_ON_DISTRIB_UNINSTALL.contains(key) + } + ) { + enqueueWorkerWithoutData("defaultUseDistributor") + enqueueNotifUnifiedPushDisabled() + } } - } - } != null + } != null /** * Run the default worker, to use FCM if available @@ -304,8 +303,8 @@ class PushRegistrationWorker( /** * Unregister on NC server and NC proxy */ - private fun unregisterProxyPush(user: User): Observable { - return if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + private fun unregisterProxyPush(user: User): Observable = + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { Log.d(TAG, "Unregistering proxy push for ${user.userId}") ncApi.unregisterDeviceForNotificationsWithNextcloud( user.getCredentials(), @@ -322,17 +321,14 @@ class PushRegistrationWorker( } else { Observable.empty() } - } /** * 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> { + @Suppress("ReturnCount") + 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) { @@ -353,16 +349,19 @@ class PushRegistrationWorker( "talk" ).map { r -> return@map when (r.code()) { - 200 -> { + HTTP_OK -> { Log.d(TAG, "Web push registration for ${user.userId} was already registered and activated\n") user to true } - 201 -> { + HTTP_CREATED -> { 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()})") + Log.d( + TAG, + "An error occurred while registering web push for ${user.userId} (status=${r.code()})" + ) user to false } } @@ -373,10 +372,7 @@ class PushRegistrationWorker( } } - private fun activateWebPushForAccount( - user: User, - activationToken: String - ) : Observable { + 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}") @@ -388,11 +384,11 @@ class PushRegistrationWorker( activationToken ).map { r -> return@map when (r.code()) { - 200 -> { + HTTP_OK -> { Log.d(TAG, "Web push registration for ${user.userId} was already activated\n") true } - 202 -> { + HTTP_CREATED -> { Log.d(TAG, "Web push registration for ${user.userId} activated") true } @@ -404,9 +400,7 @@ class PushRegistrationWorker( } } - private fun unregisterWebPushForAccount( - user: User - ) : Observable { + 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}") @@ -416,7 +410,6 @@ class PushRegistrationWorker( user.getCredentials(), ApiUtils.getUrlForWebPush(user.baseUrl!!) ).map { true } - } /** @@ -426,16 +419,15 @@ class PushRegistrationWorker( * * @return `Observable>`, true if registration succeed, false if server doesn't support web push */ - private fun registerUnifiedPushForAccount( - user: User - ): Observable> { + @Suppress("ReturnCount") + private fun registerUnifiedPushForAccount(user: User): Observable> { if (user.hasWebPushCapability) { 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() } - return ncApi.getVapidKey(user.getCredentials(),ApiUtils.getUrlForVapid(user.baseUrl!!)) + return ncApi.getVapidKey(user.getCredentials(), ApiUtils.getUrlForVapid(user.baseUrl!!)) .flatMap { ocs -> ocs.ocs?.data?.vapid?.let { vapid -> UnifiedPush.register( @@ -464,6 +456,8 @@ class PushRegistrationWorker( const val USE_UNIFIEDPUSH = "use_unifiedpush" const val UNIFIEDPUSH_ENDPOINT = "unifiedpush_endpoint" const val UNREGISTER_WEBPUSH = "unregister_webpush" + private const val HTTP_OK: Int = 200 + private const val HTTP_CREATED: Int = 201 /** * If any of these actions are present when we observe the distributor is uninstalled, 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 b21829b2b7..61b7744c05 100644 --- a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt +++ b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt @@ -1,7 +1,7 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ @@ -22,7 +22,7 @@ import org.unifiedpush.android.connector.PushService import org.unifiedpush.android.connector.data.PushEndpoint import org.unifiedpush.android.connector.data.PushMessage -class UnifiedPushService: PushService() { +class UnifiedPushService : PushService() { override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { Log.d(TAG, "New endpoint for $instance") val endpointBA = endpoint.toByteArray() ?: run { 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 82840f8927..8b603fe9f0 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -325,10 +325,9 @@ class SettingsActivity : setupServerNotificationAppCheck() } - private fun showUnifiedPushToggle(): Boolean { - return UnifiedPush.getDistributors(this).isNotEmpty() && + private fun showUnifiedPushToggle(): Boolean = + 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 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 index 25648eb508..aac47d3bf8 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt @@ -1,16 +1,16 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ -package com.nextcloud.talk.ui.dialog; +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.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -19,9 +19,7 @@ import androidx.compose.ui.res.stringResource import com.nextcloud.talk.R @Composable -fun IntroduceUnifiedPushDialog( - onResponse: (Boolean) -> Unit -) { +fun IntroduceUnifiedPushDialog(onResponse: (Boolean) -> Unit) { var showDialog by remember { mutableStateOf(true) } if (showDialog) { AlertDialog( @@ -50,7 +48,7 @@ fun IntroduceUnifiedPushDialog( }, text = { Text(stringResource(R.string.nc_dialog_introduce_unifiedpush_selection)) - }, + } ) } } 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 5a053b597c..9a74248387 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -390,11 +390,14 @@ 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" 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 bf0d53aeaa..7316a4f4c0 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt @@ -157,6 +157,7 @@ class PushUtils { return result.toString() } + @Suppress("ReturnCount") fun generateRsa2048KeyPair(): Int { if (!publicKeyFile.exists() && !privateKeyFile.exists()) { var keyGen: KeyPairGenerator? = null diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt index f84f457e63..8f387507cc 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -1,7 +1,7 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2025 Your Name + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ @@ -40,10 +40,7 @@ object UnifiedPushUtils { * @param callback: run with the push service name if available */ @JvmStatic - fun useDefaultDistributor( - activity: Activity, - callback: (String?) -> Unit - ) { + fun useDefaultDistributor(activity: Activity, callback: (String?) -> Unit) { Log.d(TAG, "Using default UnifiedPush distributor") UnifiedPush.tryUseDefaultDistributor(activity) { res -> if (res) { @@ -79,10 +76,7 @@ object UnifiedPushUtils { * @param callback: run with the push service name if available */ @JvmStatic - fun pickDistributor( - activity: Activity, - callback: (String?) -> Unit - ) { + fun pickDistributor(activity: Activity, callback: (String?) -> Unit) { Log.d(TAG, "Picking another UnifiedPush distributor") UnifiedPush.tryPickDistributor(activity as Context) { res -> if (res) { @@ -98,9 +92,7 @@ object UnifiedPushUtils { * Disable UnifiedPush and try to register with proxy push again */ @JvmStatic - fun disableExternalUnifiedPush( - context: Context - ) { + fun disableExternalUnifiedPush(context: Context) { UnifiedPush.unregister(context) enqueuePushWorker(context, false, "disableExternalUnifiedPush") } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d1ebc75f8..9357ad7b40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -137,8 +137,7 @@ How to translate with transifex: Dark Privacy Enable UnifiedPush - Receive push notifications with an external - UnifiedPush service + Receive push notifications with an external UnifiedPush service UnifiedPush Service Screen lock Lock %1$s with Android screen lock or supported biometric method From 5eabd2061cd23cca006a9c53e7a6641ff9d14174 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 30 Apr 2026 20:41:03 +0200 Subject: [PATCH 47/47] fix user for notifications Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/jobs/NotificationWorker.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 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 bc2246a14b..9ebb1ca11d 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -318,7 +318,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor .setImportant(true) if (conversation.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) { val avatarUrl = ApiUtils.getUrlForAvatar( - signatureVerification.user!!.baseUrl!!, + user.baseUrl!!, conversation.name, false, darkMode = DisplayUtils.isDarkModeOn(applicationContext) @@ -602,9 +602,8 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor if (pushMessage.type == TYPE_CHAT || pushMessage.type == TYPE_ROOM) { val token = pushMessage.id - val user = signatureVerification.user val displayName = pushMessage.subject - if (token != null && user != null && displayName.isNotEmpty()) { + if (token != null && displayName.isNotEmpty()) { kotlinx.coroutines.runBlocking { com.nextcloud.talk.conversationlist.DirectShareHelper.reportIncomingMessage( context!!,