Skip to content

Feat: Add UnifiedPush support#16526

Open
p1gp1g wants to merge 30 commits into
nextcloud:masterfrom
p1gp1g:feat/unifiedpush
Open

Feat: Add UnifiedPush support#16526
p1gp1g wants to merge 30 commits into
nextcloud:masterfrom
p1gp1g:feat/unifiedpush

Conversation

@p1gp1g

@p1gp1g p1gp1g commented Feb 16, 2026

Copy link
Copy Markdown

Following web push support in nextcloud/notifications, we can add UnifiedPush support to the Android application*, to get push notifications even without the Play Services.

The implementation follows this guide: https://unifiedpush.org/developers/ux/

  • During startup:
    • The GPlay flavor (and the others) uses FCM by default
    • The generic flavor uses UnifiedPush by default, and will use it if a distributor (= an application that can be used as a Push Service) is available
    • With the generic flavor, if the user has many distributors and hasn't selected a default yet (nearly never the case - the default is a OS wide default), a dialog is shown to introduce the OS screen.
  • Settings:
    • On the 2 flavors, if a distributor is available, you have a new setting that allows to enable/disable UnifiedPush
    • If the user has many distributors (nearly never the case - but that means the user knows what UnifiedPush is!), a 2nd setting is offered, to change the distributor

This feature also gives the possibility to get Push Notifications with the Play Services, without the proxy (Nextcloud servers directly push to Google FCM servers), and without a proprietary library. So this is an accepted way to use FCM for application in F-Droid. => This is in a 2nd PR, already ready.

Fix #11898
Fix #5510
Fix #8684
Fix #3333 (with the 2nd PR)

* We could add it to the Linux app too, but I haven't, at least yet.

🖼️ Screenshots

Coming, I will update this description

🏚️ Before 🏡 After
image Screenshot From 2026-02-16 23-11-48

🏁 Checklist

  • Tests written, or not not needed : I don't know what can be done

@p1gp1g

p1gp1g commented Feb 16, 2026

Copy link
Copy Markdown
Author

And I'll start by fixing some conflicts 👍

private fun appTypes(): List<String> = context.packageManager
.getLaunchIntentForPackage(APP_NEXTCLOUD_TALK)?.let {
listOf("all", "-talk")
} ?: listOf("all")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is to get all notifications, except talk ones if talk is installed. But:

  • We can decide to never receive talk notifications (if a user wants talk notif, they probably use Talk app)
  • And it would be better to list the applications we want to receive notifications from

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about talk notifications? Is it expected that the talk app will independently handle unifiedpush notifications too?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm cleaning up the code to open the pull request

@p1gp1g p1gp1g changed the title Feat/unifiedpush Feat: Add UnifiedPush support Feb 18, 2026
@JvmStatic
fun registerCurrentPushConfiguration(activity: Activity, accountManager: UserAccountManager, preferences: AppPreferences) {
if (
(!preferences.isPushInitialized && BuildConfig.DEFAULT_PUSH_UNIFIEDPUSH)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using a BuildConfig, we could add a function isPlayServiceAvailable to PushUtils (Implemented per flavor, dedicated to FCM and proxy-push) to check if the Play Services are available.

That way, a user using the gplay flavor on a phone without the Play Services can use by default UnifiedPush.

But it introduces a bit more change, and it is a bit less readable. As you want

@o-kotb

This comment was marked as off-topic.

@p1gp1g

This comment was marked as off-topic.

@Elsensee

This comment was marked as off-topic.

@o-kotb

This comment was marked as off-topic.

@p1gp1g

This comment was marked as off-topic.

@github-actions

github-actions Bot commented Mar 3, 2026

Copy link
Copy Markdown

Hello there,
Thank you so much for taking the time and effort to create a pull request to our Nextcloud project.

We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process.

Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6

Thank you for contributing to Nextcloud and we hope to hear from you soon!

(If you believe you should not receive this message, you can add yourself to the blocklist.)

@alperozturk96

Copy link
Copy Markdown
Collaborator

Hi! Thanks for your contribution.

Could you please resolve conflict? Then we can review.

p1gp1g added 10 commits July 2, 2026 11:43
Signed-off-by: sim <git@sgougeon.fr>
The capability doesn't have an accountName, and therefore doesn't update the user cache

Signed-off-by: sim <git@sgougeon.fr>
…e message

Signed-off-by: sim <git@sgougeon.fr>
Signed-off-by: sim <git@sgougeon.fr>
Signed-off-by: sim <git@sgougeon.fr>
Signed-off-by: sim <git@sgougeon.fr>
p1gp1g added 8 commits July 2, 2026 11:54
To catch easily when we do UnifiedPush or Common push tasks

Signed-off-by: sim <git@sgougeon.fr>
Signed-off-by: sim <git@sgougeon.fr>
… a distributor

Signed-off-by: sim <git@sgougeon.fr>
…nregistration

Signed-off-by: sim <git@sgougeon.fr>
Signed-off-by: sim <git@sgougeon.fr>
@p1gp1g

p1gp1g commented Jul 2, 2026

Copy link
Copy Markdown
Author

I'm doing it

@p1gp1g

p1gp1g commented Jul 2, 2026

Copy link
Copy Markdown
Author

BTW, we need this PR too: nextcloud/android-library#1949

Signed-off-by: sim <git@sgougeon.fr>
@p1gp1g p1gp1g force-pushed the feat/unifiedpush branch from 84bf56d to 8ac264a Compare July 2, 2026 11:45
@p1gp1g

p1gp1g commented Jul 2, 2026

Copy link
Copy Markdown
Author

The PR is rebased, and conflict free 👍

Signed-off-by: sim <git@sgougeon.fr>
@Fuseteam

Fuseteam commented Jul 3, 2026

Copy link
Copy Markdown

hmmm github still complains about conflicts 🤔 or is the webui out of date?

public String deviceIdentifierSignature;
public String userPublicKey;
public boolean shouldBeDeleted;
public boolean shouldBeDisabled = false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need two boolean flag?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The first one, shouldBeDisabled informs the next push notification worker to disable (proxy) push (where disabled will be set to true)
  • the second one disabled when we have successfully disabled (proxy) push notifications

It works like with shouldBeDeleted:

  • shouldBeDeleted inform the next push notification worker to delete push notification data (where accountPushData will be wiped)
  • once deleted (accountPushData == null), it can't be used

Comment thread app/src/main/java/com/owncloud/android/services/UnifiedPushService.kt Outdated

if (remoteOperationResult.getHttpCode() == HttpStatus.SC_ACCEPTED) {
int status = remoteOperationResult.getHttpCode();
if (deleteLocalData && status == HttpStatus.SC_ACCEPTED) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why deleteLocalData needs to be true for UnregisterAccountDeviceForProxyOperation.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteLocalData may not be the best name. It describes "delete user public key, and unsubscribe from the proxy".

The idea is that we can unregister for proxy push notifications in 3 cases:

  • When we delete an account
  • When we disable proxy-push
  • When we hard-reset push subscriptions (and rotate encryption keys)

In the first and last case, we want either to wipe our current keys, and unregister on the proxy. In the 2nd case, that may simply be temporary, we don't need/want to unsubscribe

Comment thread app/src/main/java/com/nextcloud/client/jobs/BackgroundJobManagerImpl.kt Outdated
Comment thread app/src/main/java/com/nextcloud/client/jobs/NotificationWork.kt Outdated
Comment thread app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java Outdated
){
tryUseUnifiedPush(activity, accountManager, preferences) {}
} else {
CoroutineScope(Dispatchers.IO).launch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use lifecycle scope of passed activity.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to not be tied to the activity lifecycle for that. Is it OK for you if I set a private val scope = CoroutineScope(Dispatchers.IO) like we have for FileUploadHelper ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function will be triggered with class TokenPushEvent Eventbus. And it is called from FileDisplayActivity.onStart.initFile and onRequestPermissionsResult.PermissionUtil.PERMISSIONS_EXTERNAL_STORAGE

Why it is not better to tied to the activity lifecycle?

FileUploadHelper.ioScope should also have cancellation function. We want to avoid this in new PRs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This job makes a request that isn't made for its response (which would have been used for the UI), but to change the state on the server. We don't want the job to finish if the user changes the activity

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay then so tied to the termination of the app?

  1. Assign to a job current implementation and expose via cancel function then call cancel function termination of the app from MainApp.java

  2. Internally add a mechanism to cancellation after n minutes and reassessing if its triggered.

What do you think?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking about a scope that forces the job to be run in a timeout. I'm pushing something to show, let me know what you think about after that

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here it is: 3e31982 what do you think ?

accountManager: UserAccountManager,
account: OwnCloudAccount
) {
CoroutineScope(Dispatchers.IO).launch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creates an unmanaged scope. Every call creates a new independent CoroutineScope that isn't tied to any lifecycle. If the Activity is destroyed or the operation should be cancelled, these coroutines keep running.

) {
CoroutineScope(Dispatchers.IO).launch {
val jobs = accountManager.accounts.map { account ->
CoroutineScope(Dispatchers.IO).launch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested scopes are unnecessary and also needs to be managed scope.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is the jobs.joinAll() later, that's to avoid sending requests sequentially

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If registerUnifiedPushForAccount throws for one account rest will be affected as well.

You can use supervisorScope and also please handle cancelation of the scope (CoroutineScope(Dispatchers.IO)).

Documentation supervisorScope

CoroutineScope(Dispatchers.IO).launch {
    supervisorScope {
        val jobs = accountManager.accounts.map { account ->
            launch {
                try {
                    val ocAccount = OwnCloudAccount(account, context)
                    val res = registerUnifiedPushForAccount(context, accountManager, ocAccount)
                    if (res) {
                        PushUtils.setRegistrationForAccountEnabled(account, false)
                    }
                } catch (e: Exception) {
                    Log_OC.e(TAG, "Failed to register push for ${account.name}", e)
                }
            }
        }
        jobs.joinAll()
    }
    proxyPushToken?.let {
        PushUtils.pushRegistrationToServer(accountManager, it)
    }
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I'm pushing the change, and I'll use the scope within timeout

p1gp1g added 2 commits July 8, 2026 16:28
Signed-off-by: sim <git@sgougeon.fr>

@p1gp1g p1gp1g left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review. I'm pushing a few commits.

I tried to answer some of your comments. Do you have a matrix account, or anywhere to communicate directly ? It will be easier to discuss a few points.


if (remoteOperationResult.getHttpCode() == HttpStatus.SC_ACCEPTED) {
int status = remoteOperationResult.getHttpCode();
if (deleteLocalData && status == HttpStatus.SC_ACCEPTED) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleteLocalData may not be the best name. It describes "delete user public key, and unsubscribe from the proxy".

The idea is that we can unregister for proxy push notifications in 3 cases:

  • When we delete an account
  • When we disable proxy-push
  • When we hard-reset push subscriptions (and rotate encryption keys)

In the first and last case, we want either to wipe our current keys, and unregister on the proxy. In the 2nd case, that may simply be temporary, we don't need/want to unsubscribe

Comment thread app/src/main/java/com/nextcloud/client/jobs/UnifiedPushWork.kt Outdated
public String deviceIdentifierSignature;
public String userPublicKey;
public boolean shouldBeDeleted;
public boolean shouldBeDisabled = false;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The first one, shouldBeDisabled informs the next push notification worker to disable (proxy) push (where disabled will be set to true)
  • the second one disabled when we have successfully disabled (proxy) push notifications

It works like with shouldBeDeleted:

  • shouldBeDeleted inform the next push notification worker to delete push notification data (where accountPushData will be wiped)
  • once deleted (accountPushData == null), it can't be used

Comment thread app/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java Outdated
){
tryUseUnifiedPush(activity, accountManager, preferences) {}
} else {
CoroutineScope(Dispatchers.IO).launch {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to not be tied to the activity lifecycle for that. Is it OK for you if I set a private val scope = CoroutineScope(Dispatchers.IO) like we have for FileUploadHelper ?

) {
CoroutineScope(Dispatchers.IO).launch {
val jobs = accountManager.accounts.map { account ->
CoroutineScope(Dispatchers.IO).launch {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is the jobs.joinAll() later, that's to avoid sending requests sequentially

) {
CoroutineScope(Dispatchers.IO).launch {
val jobs = accountManager.accounts.map { account ->
CoroutineScope(Dispatchers.IO).launch {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If registerUnifiedPushForAccount throws for one account rest will be affected as well.

You can use supervisorScope and also please handle cancelation of the scope (CoroutineScope(Dispatchers.IO)).

Documentation supervisorScope

CoroutineScope(Dispatchers.IO).launch {
    supervisorScope {
        val jobs = accountManager.accounts.map { account ->
            launch {
                try {
                    val ocAccount = OwnCloudAccount(account, context)
                    val res = registerUnifiedPushForAccount(context, accountManager, ocAccount)
                    if (res) {
                        PushUtils.setRegistrationForAccountEnabled(account, false)
                    }
                } catch (e: Exception) {
                    Log_OC.e(TAG, "Failed to register push for ${account.name}", e)
                }
            }
        }
        jobs.joinAll()
    }
    proxyPushToken?.let {
        PushUtils.pushRegistrationToServer(accountManager, it)
    }
}

fun startNotificationJob(subject: String, signature: String)
fun startDecryptedNotificationJob(accountName: String, message: String)

sealed class WebPushJobData() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put WebPushJobData in owncloud/android/datamodel package.

p1gp1g added 5 commits July 9, 2026 11:27
Signed-off-by: sim <git@sgougeon.fr>
Signed-off-by: sim <git@sgougeon.fr>
Signed-off-by: sim <git@sgougeon.fr>
Signed-off-by: sim <git@sgougeon.fr>
Signed-off-by: sim <git@sgougeon.fr>
@p1gp1g p1gp1g force-pushed the feat/unifiedpush branch from 1cef5e2 to 5960917 Compare July 9, 2026 09:27
Signed-off-by: sim <git@sgougeon.fr>
@p1gp1g p1gp1g force-pushed the feat/unifiedpush branch from de05ddb to 3eb072a Compare July 9, 2026 09:33
Signed-off-by: sim <git@sgougeon.fr>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

7 participants