diff --git a/app/build.gradle b/app/build.gradle index a2424e32584..ba3e678e589 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -182,6 +182,13 @@ configurations.configureEach { exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' exclude group: 'org.jetbrains', module: 'annotations-java5' // via prism4j, already using annotations explicitly + String protobufJava = "com.google.protobuf:protobuf-java:4.28.2" + resolutionStrategy { + force(protobufJava) + dependencySubstitution { + substitute module("com.google.protobuf:protobuf-javalite") using module(protobufJava) + } + } } dependencies { @@ -261,6 +268,9 @@ dependencies { implementation 'com.github.lukaspili.autodagger2:autodagger2:1.1' kapt 'com.github.lukaspili.autodagger2:autodagger2-compiler:1.1' compileOnly 'javax.annotation:javax.annotation-api:1.3.2' + // Until https://github.com/google/dagger/pull/5062 + kapt 'org.jetbrains.kotlin:kotlin-metadata-jvm:2.3.0' + implementation 'org.greenrobot:eventbus:3.3.1' implementation 'net.zetetic:sqlcipher-android:4.13.0' @@ -320,6 +330,9 @@ dependencies { gplayImplementation 'com.google.android.gms:play-services-base:18.10.0' gplayImplementation "com.google.firebase:firebase-messaging:25.0.1" + implementation 'org.unifiedpush.android:connector:3.3.1' + genericImplementation 'org.unifiedpush.android:embedded-fcm-distributor:3.1.0-rc1' + //compose implementation(platform("androidx.compose:compose-bom:2026.02.00")) implementation("androidx.compose.ui:ui") @@ -422,4 +435,4 @@ detekt { ksp { arg('room.schemaLocation', "$projectDir/schemas") -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a6ef2dc6bb5..285c6cb7acc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -312,6 +312,13 @@ android:exported="false" android:foregroundServiceType="microphone|camera" /> + + + + + + CAPABILITIES_FETCH + // -> PUSH_REGISTRATION + // -> SIGNALING_SETTINGS + when (eventStatus.eventType) { + EventStatus.EventType.PROFILE_STORED -> { + fetchAndStoreCapabilities() + } + 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() - } else if (eventStatus.eventType == EventStatus.EventType.CAPABILITIES_FETCH) { - if (internalAccountId == eventStatus.userId && !eventStatus.isAllGood) { - runOnUiThread { - binding.progressText.text = - """ + 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 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 (!eventStatus.isAllGood) { + runOnUiThread { + binding.progressText.text = + """ ${binding.progressText.text} ${resources!!.getString(R.string.nc_external_server_failed)} """.trimIndent() + } + } + proceedWithLogin() + } + else -> {} + } + } + + private fun setupPushNotifications() { + // 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)) + return + } else { + Log.w(TAG, "Warning: disabling UnifiedPush, user server doesn't support web push.") + appPreferences.useUnifiedPush = false + } + } + + // - By default, use the Play Services if available + // - If this is a first user, and we have an External UnifiedPush distributor, + // and the server supports it: we use it + // - Else if there is an embedded distributor (so this is a generic flavor, and the + // Play services are installed) => we use it for all accounts that support web push + // - Else we skip push registrations + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + ClosedInterfaceImpl().setUpPushTokenRegistration() + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + } else if (userManager.users.blockingGet().size == 1 && + UnifiedPushUtils.getExternalDistributors(context).isNotEmpty() && + userManager.getUserWithId(internalAccountId).blockingGet().hasWebPushCapability) { + useUnifiedPushIntroduced() + } else if (UnifiedPushUtils.hasEmbeddedDistributor(context) && + userManager.users.blockingGet().any { it.hasWebPushCapability }) { + useEmbeddedUnifiedPush() + } else { + Log.w(TAG, "Skipping push registration.") + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } + + /** + * 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 { + fallbackToEmbeddedUnifiedPush() + } + } + } else { + useUnifiedPush() + } + } + + /** + * Check if there is an embedded distributor, and use it if present, + * else, send EventStatus PUSH_REGISTRATION with success=false + */ + private fun fallbackToEmbeddedUnifiedPush() { + if (UnifiedPushUtils.hasEmbeddedDistributor(context)) { + useEmbeddedUnifiedPush() + } else { + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, false)) + } + } + + private fun useEmbeddedUnifiedPush() { + UnifiedPushUtils.useEmbeddedDistributor(context) + UnifiedPushUtils.registerWithCurrentDistributor(context) + eventBus.post(EventStatus(internalAccountId, EventStatus.EventType.PUSH_REGISTRATION, true)) + } + + private fun useUnifiedPush() { + UnifiedPushUtils.useDefaultDistributor(this) { distrib -> + distrib?.let { + 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") + fallbackToEmbeddedUnifiedPush() + } + } + } + + private fun dialogForUnifiedPush(onResponse: (Boolean) -> Unit) { + binding.genericComposeView.apply { + setContent { + IntroduceUnifiedPushDialog { res -> + onResponse(res) } } - proceedWithLogin() } } diff --git a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt index 4c5c6b50dcb..d42d82c8bb4 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/activities/MainActivity.kt @@ -38,6 +38,7 @@ import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.SecurityUtils +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import io.reactivex.Observer @@ -260,7 +261,11 @@ class MainActivity : override fun onSuccess(users: List) { if (users.isNotEmpty()) { - ClosedInterfaceImpl().setUpPushTokenRegistration() + if (appPreferences.useUnifiedPush) { + UnifiedPushUtils.setPeriodicPushRegistrationWorker(this@MainActivity) + } else { + ClosedInterfaceImpl().setUpPushTokenRegistration() + } runOnUiThread { openConversationList() } diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApi.java b/app/src/main/java/com/nextcloud/talk/api/NcApi.java index 972c0b1f738..934129321bb 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/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 402b266a0b2..c659a5c8c29 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -128,6 +128,7 @@ import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.SpreedFeatures +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.ADD_ADDITIONAL_ACCOUNT @@ -282,7 +283,9 @@ class ConversationsListActivity : // handle notification permission on API level >= 33 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !platformPermissionUtil.isPostNotificationsPermissionGranted() && - ClosedInterfaceImpl().isGooglePlayServicesAvailable + (ClosedInterfaceImpl().isGooglePlayServicesAvailable || + appPreferences.useUnifiedPush || + UnifiedPushUtils.hasEmbeddedDistributor(context)) ) { requestPermissions( arrayOf(Manifest.permission.POST_NOTIFICATIONS), diff --git a/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt b/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt index a94ec01044a..34443960e5b 100644 --- a/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt +++ b/app/src/main/java/com/nextcloud/talk/data/user/model/User.kt @@ -35,6 +35,9 @@ data class User( var scheduledForDeletion: Boolean = FALSE ) : Parcelable { + val hasWebPushCapability: Boolean + get() = capabilities?.notificationsCapability?.push?.contains("webpush") == true + fun getCredentials(): String = ApiUtils.getCredentials(username, token)!! fun hasSpreedFeatureCapability(capabilityName: String): Boolean { diff --git a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt index 22a112ddc86..ae44be55090 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseActivity.kt @@ -50,9 +50,11 @@ import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.PushUtils.Companion.LATEST_PUSH_REGISTRATION_AT_PUSH_PROXY import com.nextcloud.talk.utils.PushUtils.Companion.LATEST_PUSH_REGISTRATION_AT_SERVER +import com.nextcloud.talk.utils.UnifiedPushUtils import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils +import org.unifiedpush.android.connector.UnifiedPush import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -78,6 +80,12 @@ class DiagnoseActivity : BaseActivity() { lateinit var platformPermissionUtil: PlatformPermissionUtil private var isGooglePlayServicesAvailable: Boolean = false + private var useEmbeddedDistrib: Boolean = false + + private var nUnifiedPushServices = 0 + private var offerUnifiedPush: Boolean = false + private var useUnifiedPush: Boolean = false + private var unifiedPushService: String = "" sealed class DiagnoseElement { data class DiagnoseHeadline(val headline: String) : DiagnoseElement() @@ -97,6 +105,12 @@ class DiagnoseActivity : BaseActivity() { val colorScheme = viewThemeUtils.getColorScheme(this) isGooglePlayServicesAvailable = ClosedInterfaceImpl().isGooglePlayServicesAvailable + nUnifiedPushServices = UnifiedPushUtils.getExternalDistributors(this).size + offerUnifiedPush = nUnifiedPushServices > 0 && + userManager.users.blockingGet().all { it.hasWebPushCapability } + useUnifiedPush = appPreferences.useUnifiedPush + useEmbeddedDistrib = UnifiedPushUtils.hasEmbeddedDistributor(context) && !useUnifiedPush + unifiedPushService = UnifiedPush.getAckDistributor(this) ?: "N/A" setContent { val backgroundColor = colorResource(id = R.color.bg_default) @@ -149,7 +163,9 @@ class DiagnoseActivity : BaseActivity() { viewState = viewState, onTestPushClick = { diagnoseViewModel.fetchTestPushResult() }, onDismissDialog = { diagnoseViewModel.dismissDialog() }, - isGooglePlayServicesAvailable = isGooglePlayServicesAvailable, + showTestPushButton = isGooglePlayServicesAvailable || + useUnifiedPush || + useEmbeddedDistrib, isOnline = isOnline ) } @@ -243,7 +259,7 @@ class DiagnoseActivity : BaseActivity() { value = Build.VERSION.SDK_INT.toString() ) - if (isGooglePlayServicesAvailable) { + if (isGooglePlayServicesAvailable || useEmbeddedDistrib) { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnose_gplay_available_title), value = context.resources.getString(R.string.nc_diagnose_gplay_available_yes) @@ -251,9 +267,13 @@ class DiagnoseActivity : BaseActivity() { } else { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnose_gplay_available_title), - value = context.resources.getString(R.string.nc_diagnose_gplay_available_no) + value = context.resources.getString(R.string.nc_diagnose_gplay_available_no_short) ) } + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_unifiedpush_available_title), + value = getString(R.string.nc_diagnose_unifiedpush_available_n).format(nUnifiedPushServices) + ) } @SuppressLint("SetTextI18n") @@ -276,7 +296,21 @@ class DiagnoseActivity : BaseActivity() { value = BuildConfig.FLAVOR ) - if (isGooglePlayServicesAvailable) { + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_offer_unifiedpush), + value = translateBoolean(offerUnifiedPush) + ) + + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_use_unifiedpush), + value = translateBoolean(useUnifiedPush) + ) + + if (useUnifiedPush || useEmbeddedDistrib) { + setupAppValuesForPush() + setupAppValuesForUnifiedPush() + } else if (isGooglePlayServicesAvailable) { + setupAppValuesForPush() setupAppValuesForGooglePlayServices() } @@ -286,8 +320,7 @@ class DiagnoseActivity : BaseActivity() { ) } - @Suppress("Detekt.LongMethod") - private fun setupAppValuesForGooglePlayServices() { + private fun setupAppValuesForPush() { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnose_battery_optimization_title), value = if (PowerManagerUtils().isIgnoringBatteryOptimizations()) { @@ -324,7 +357,31 @@ class DiagnoseActivity : BaseActivity() { NotificationUtils.isMessagesNotificationChannelEnabled(this) ) ) + } + + private fun setupAppValuesForUnifiedPush() { + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_unifiedpush_service), + value = unifiedPushService + ) + addDiagnosisEntry( + key = context.resources.getString(R.string.nc_diagnose_unifiedpush_latest_endpoint), + value = if (appPreferences.unifiedPushLatestEndpoint != null && + appPreferences.unifiedPushLatestEndpoint != 0L + ) { + DisplayUtils.unixTimeToHumanReadable( + appPreferences + .unifiedPushLatestEndpoint + ) + } else { + context.resources.getString(R.string.nc_common_unknown) + } + ) + } + + @Suppress("Detekt.LongMethod") + private fun setupAppValuesForGooglePlayServices() { addDiagnosisEntry( key = context.resources.getString(R.string.nc_diagnose_firebase_push_token_title), value = if (appPreferences.pushToken.isNullOrEmpty()) { @@ -389,6 +446,11 @@ class DiagnoseActivity : BaseActivity() { translateBoolean(currentUser.capabilities?.notificationsCapability?.features?.isNotEmpty()) ) + addDiagnosisEntry( + key = getString(R.string.nc_diagnose_server_supports_webpush), + value = translateBoolean(currentUser.hasWebPushCapability) + ) + if (isGooglePlayServicesAvailable) { setupPushRegistrationDiagnose() } diff --git a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseContentComposable.kt b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseContentComposable.kt index 29c1be2127b..103b8873949 100644 --- a/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseContentComposable.kt +++ b/app/src/main/java/com/nextcloud/talk/diagnose/DiagnoseContentComposable.kt @@ -59,7 +59,7 @@ fun DiagnoseContentComposable( viewState: NotificationUiState, onTestPushClick: () -> Unit, onDismissDialog: () -> Unit, - isGooglePlayServicesAvailable: Boolean, + showTestPushButton: Boolean, isOnline: Boolean ) { val context = LocalContext.current @@ -102,7 +102,7 @@ fun DiagnoseContentComposable( } } } - if (isGooglePlayServicesAvailable && isOnline) { + if (showTestPushButton && isOnline) { ShowTestPushButton(onTestPushClick) } ShowNotificationData(isLoading, showDialog, context, viewState, onDismissDialog) diff --git a/app/src/main/java/com/nextcloud/talk/events/EventStatus.java b/app/src/main/java/com/nextcloud/talk/events/EventStatus.java index c8470903b9f..f8e1af6b16e 100644 --- a/app/src/main/java/com/nextcloud/talk/events/EventStatus.java +++ b/app/src/main/java/com/nextcloud/talk/events/EventStatus.java @@ -84,7 +84,8 @@ public String toString() { } public enum EventType { - PUSH_REGISTRATION, CAPABILITIES_FETCH, SIGNALING_SETTINGS, CONVERSATION_UPDATE, PARTICIPANTS_UPDATE + PROFILE_STORED, PUSH_REGISTRATION, CAPABILITIES_FETCH, SIGNALING_SETTINGS, CONVERSATION_UPDATE, + PARTICIPANTS_UPDATE } } 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 b0a667ee552..9f921c77daa 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt @@ -48,7 +48,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.callnotification.CallNotificationActivity import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource -import com.nextcloud.talk.models.SignatureVerification +import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.chat.ChatUtils.Companion.getParsedMessage import com.nextcloud.talk.models.json.conversations.ConversationEnums @@ -133,7 +133,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor private lateinit var credentials: String private lateinit var ncApi: NcApi private lateinit var pushMessage: DecryptedPushMessage - private lateinit var signatureVerification: SignatureVerification + private lateinit var user: User private var context: Context? = null private var conversationType: String? = "one2one" private lateinit var notificationManager: NotificationManagerCompat @@ -156,12 +156,12 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor Log.d(TAG, "pushMessage.timestamp: " + pushMessage.timestamp) if (pushMessage.delete) { - cancelNotification(context, signatureVerification.user!!, pushMessage.notificationId) + cancelNotification(context, user, pushMessage.notificationId) } else if (pushMessage.deleteAll) { - cancelAllNotificationsForAccount(context, signatureVerification.user!!) + cancelAllNotificationsForAccount(context, user) } else if (pushMessage.deleteMultiple) { for (notificationId in pushMessage.notificationIds!!) { - cancelNotification(context, signatureVerification.user!!, notificationId) + cancelNotification(context, user, notificationId) } } else if (isTalkNotification()) { Log.d(TAG, "pushMessage.type: " + pushMessage.type) @@ -174,9 +174,11 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } else if (isAdminTalkNotification()) { Log.d(TAG, "pushMessage.type: " + pushMessage.type) when (pushMessage.type) { - TYPE_ADMIN_NOTIFICATIONS -> handleTestPushMessage() + TYPE_ADMIN_NOTIFICATIONS -> handleInternalPushMessage() else -> Log.e(TAG, pushMessage.type + " is not handled") } + } else if (isInternal()) { + handleInternalPushMessage() } else { Log.d(TAG, "a pushMessage that is not for spreed was received.") } @@ -184,7 +186,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor return Result.success() } - private fun handleTestPushMessage() { + private fun handleInternalPushMessage() { val intent = Intent(context, MainActivity::class.java) intent.flags = getIntentFlags() showNotification(intent, null) @@ -199,20 +201,20 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val mainActivityIntent = Intent(context, MainActivity::class.java) mainActivityIntent.flags = getIntentFlags() val bundle = Bundle() - bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!) + bundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) bundle.putBoolean(KEY_REMOTE_TALK_SHARE, true) mainActivityIntent.putExtras(bundle) getNcDataAndShowNotification(mainActivityIntent) } private fun handleCallPushMessage() { - val userBeingCalled = userManager.getUserWithId(signatureVerification.user!!.id!!).blockingGet() + val userBeingCalled = userManager.getUserWithId(user.id!!).blockingGet() fun createBundle(conversation: ConversationModel): Bundle { val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, pushMessage.id) bundle.putInt(KEY_NOTIFICATION_TIMESTAMP, pushMessage.timestamp.toInt()) - bundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!) + bundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) bundle.putBoolean(KEY_FROM_NOTIFICATION_START_CALL, true) val isOneToOneCall = conversation.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL @@ -263,7 +265,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor val soundUri = getCallRingtoneUri(applicationContext, appPreferences) val notificationChannelId = NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_CALLS_V4.name - val uri = signatureVerification.user!!.baseUrl!!.toUri() + val uri = user.baseUrl!!.toUri() val baseUrl = uri.host val notification = @@ -287,7 +289,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor sendNotification(pushMessage.timestamp.toInt(), notification) - checkIfCallIsActive(signatureVerification, conversation) + checkIfCallIsActive(conversation) } chatNetworkDataSource?.getRoom(userBeingCalled, roomToken = pushMessage.id!!) @@ -315,10 +317,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } private fun initNcApiAndCredentials() { - credentials = ApiUtils.getCredentials( - signatureVerification.user!!.username, - signatureVerification.user!!.token - )!! + credentials = user.getCredentials() ncApi = retrofit!!.newBuilder().client( okHttpClient!!.newBuilder().cookieJar( JavaNetCookieJar( @@ -332,15 +331,24 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor @Suppress("TooGenericExceptionCaught", "NestedBlockDepth", "ComplexMethod", "LongMethod") private fun initDecryptedData(inputData: Data) { - val subject = inputData.getString(BundleKeys.KEY_NOTIFICATION_SUBJECT) - val signature = inputData.getString(BundleKeys.KEY_NOTIFICATION_SIGNATURE) try { + if (inputData.hasKeyWithValueOfType(BundleKeys.KEY_NOTIFICATION_CLEARTEXT_SUBJECT, String::class.java)) { + val subject = inputData.getString(BundleKeys.KEY_NOTIFICATION_CLEARTEXT_SUBJECT) + val id = inputData.getLong(BundleKeys.KEY_NOTIFICATION_USER_ID, -1) + user = userManager.getUserWithId(id).blockingGet() + pushMessage = LoganSquare.parse(subject, DecryptedPushMessage::class.java) + return + } + + val subject = inputData.getString(BundleKeys.KEY_NOTIFICATION_SUBJECT) + val signature = inputData.getString(BundleKeys.KEY_NOTIFICATION_SIGNATURE) + val base64DecodedSubject = Base64.decode(subject, Base64.DEFAULT) val base64DecodedSignature = Base64.decode(signature, Base64.DEFAULT) val pushUtils = PushUtils() val privateKey = pushUtils.readKeyFromFile(false) as PrivateKey try { - signatureVerification = pushUtils.verifySignature( + val signatureVerification = pushUtils.verifySignature( base64DecodedSignature, base64DecodedSubject ) @@ -353,6 +361,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) @@ -367,17 +376,16 @@ 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 private fun getNcDataAndShowNotification(intent: Intent) { - val user = signatureVerification.user - // see https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md ncApi.getNcNotification( credentials, ApiUtils.getUrlForNcNotificationWithId( - user!!.baseUrl!!, + user.baseUrl!!, pushMessage.notificationId.toString() ) ) @@ -496,7 +504,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? = "" @@ -525,11 +533,13 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor notificationBuilder.setLargeIcon(getLargeIcon()) } - val activeStatusBarNotification = findNotificationForRoom( - context, - signatureVerification.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. @@ -574,7 +584,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor .setColor(context!!.resources.getColor(R.color.colorPrimary, null)) val notificationInfoBundle = Bundle() - notificationInfoBundle.putLong(KEY_INTERNAL_USER_ID, signatureVerification.user!!.id!!) + notificationInfoBundle.putLong(KEY_INTERNAL_USER_ID, user.id!!) // could be an ID or a TOKEN notificationInfoBundle.putString(KEY_ROOM_TOKEN, pushMessage.id) notificationInfoBundle.putLong(KEY_NOTIFICATION_ID, pushMessage.notificationId!!) @@ -599,7 +609,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor } notificationBuilder.setContentIntent(pendingIntent) - val groupName = signatureVerification.user!!.id.toString() + "@" + pushMessage.id + val groupName = user.id.toString() + "@" + pushMessage.id notificationBuilder.setGroup(calculateCRC32(groupName).toString()) return notificationBuilder } @@ -657,12 +667,12 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor ) } val person = Person.Builder() - .setKey(signatureVerification.user!!.id.toString() + "@" + notificationUser.id) + .setKey(user.id.toString() + "@" + notificationUser.id) .setName(EmojiCompat.get().process(notificationUser.name!!)) .setBot("bot" == userType) if ("user" == userType || "guest" == userType) { - val baseUrl = signatureVerification.user!!.baseUrl + val baseUrl = user.baseUrl val avatarUrl = if ("user" == userType) { ApiUtils.getUrlForAvatar( baseUrl!!, @@ -683,7 +693,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor // NOTE - systemNotificationId is an internal ID used on the device only. // It is NOT the same as the notification ID used in communication with the server. actualIntent.putExtra(KEY_SYSTEM_NOTIFICATION_ID, systemNotificationId) - actualIntent.putExtra(KEY_INTERNAL_USER_ID, signatureVerification.user?.id) + actualIntent.putExtra(KEY_INTERNAL_USER_ID, user.id) actualIntent.putExtra(KEY_ROOM_TOKEN, pushMessage.id) actualIntent.putExtra(KEY_MESSAGE_ID, messageId) @@ -872,13 +882,13 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor notificationManager.cancel(notificationId) } - private fun checkIfCallIsActive(signatureVerification: SignatureVerification, conversation: ConversationModel) { + private fun checkIfCallIsActive(conversation: ConversationModel) { Log.d(TAG, "checkIfCallIsActive") var hasParticipantsInCall = true var inCallOnDifferentDevice = false val apiVersion = ApiUtils.getConversationApiVersion( - signatureVerification.user!!, + user, intArrayOf(ApiUtils.API_V4, 1) ) @@ -888,7 +898,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor credentials, ApiUtils.getUrlForCall( apiVersion, - signatureVerification.user!!.baseUrl!!, + user.baseUrl!!, pushMessage.id!! ) ) @@ -906,7 +916,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor hasParticipantsInCall = participantList.isNotEmpty() if (hasParticipantsInCall) { for (participant in participantList) { - if (participant.actorId == signatureVerification.user!!.userId && + if (participant.actorId == user.userId && participant.actorType == Participant.ActorType.USERS ) { inCallOnDifferentDevice = true @@ -1002,7 +1012,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 @@ -1032,6 +1042,7 @@ class NotificationWorker(context: Context, workerParams: WorkerParameters) : Wor private const val TYPE_REMINDER = "reminder" private const val TYPE_ADMIN_NOTIFICATIONS = "admin_notifications" private const val SPREED_APP = "spreed" + private const val INTERNAL = "internal" private const val ADMIN_NOTIFICATION_TALK = "admin_notification_talk" private const val TIMER_START = 1 private const val TIMER_COUNT = 12 diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.java b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.java deleted file mode 100644 index 80eefee6506..00000000000 --- a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Andy Scherzinger - * SPDX-FileCopyrightText: 2022 Marcel Hibbe - * SPDX-FileCopyrightText: 2017 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.jobs; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.work.Data; -import androidx.work.Worker; -import androidx.work.WorkerParameters; -import autodagger.AutoInjector; -import okhttp3.CookieJar; -import okhttp3.OkHttpClient; -import retrofit2.Retrofit; - -import com.nextcloud.talk.api.NcApi; -import com.nextcloud.talk.application.NextcloudTalkApplication; -import com.nextcloud.talk.utils.ClosedInterfaceImpl; -import com.nextcloud.talk.utils.PushUtils; - -import java.net.CookieManager; - -import javax.inject.Inject; - -@AutoInjector(NextcloudTalkApplication.class) -public class PushRegistrationWorker extends Worker { - public static final String TAG = "PushRegistrationWorker"; - public static final String ORIGIN = "origin"; - - @Inject - Retrofit retrofit; - - @Inject - OkHttpClient okHttpClient; - - public PushRegistrationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { - super(context, workerParams); - } - - @NonNull - @Override - public Result doWork() { - NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this); - if (new ClosedInterfaceImpl().isGooglePlayServicesAvailable()) { - Data data = getInputData(); - String origin = data.getString("origin"); - Log.d(TAG, "PushRegistrationWorker called via " + origin); - - NcApi ncApi = retrofit - .newBuilder() - .client(okHttpClient - .newBuilder() - .cookieJar(CookieJar.NO_COOKIES) - .build()) - .build() - .create(NcApi.class); - - PushUtils pushUtils = new PushUtils(); - pushUtils.generateRsa2048KeyPair(); - pushUtils.pushRegistrationToServer(ncApi); - - return Result.success(); - } - Log.w(TAG, "executing PushRegistrationWorker doesn't make sense because Google Play Services are not " + - "available"); - return Result.failure(); - } -} diff --git a/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt new file mode 100644 index 00000000000..d8e1092503f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/jobs/PushRegistrationWorker.kt @@ -0,0 +1,483 @@ +/* + * 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.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +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.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.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 +import org.unifiedpush.android.connector.data.PushEndpoint +import retrofit2.Retrofit +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 [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) + * - 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) { + @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 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) + // 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)") + webPushActivationWork(userId, activationToken) + } 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() + } else if (UnifiedPushUtils.hasEmbeddedDistributor(applicationContext)) { + Log.d(TAG, "PushRegistrationWorker called via $origin (unifiedPushWork#embeddedDistrib)") + UnifiedPushUtils.useEmbeddedDistributor(applicationContext) + unifiedPushWork() + } else { + Log.d(TAG, "PushRegistrationWorker called via $origin (proxyPushWork)") + proxyPushWork() + } + 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) + .flatMap { 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.e(TAG, "An error occurred while activating web push, or unregistering proxy push", e) + } + } + } + + /** + * Register for web push (on server) + */ + @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) -> + 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.e(TAG, "An error occurred while registering for web push", e) + } + } + } + + /** + * 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) + */ + @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.e(TAG, "An error occurred while registering for UnifiedPush", e) + } + } + } + + /** + * 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 + } + if (user.hasWebPushCapability) { + UnifiedPush.unregister(applicationContext, UnifiedPushUtils.instanceFor(user)) + unregisterWebPushForAccount(user) + } else { + Observable.empty() + } + } + Observable.merge(obs) + .toList() + .subscribeOn(Schedulers.io()) + .subscribe { _, e -> + e?.let { + 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() + } + } + + 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 + + /** + * 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) + } + + /** + * 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 + * + * This must not be called when UnifiedPush is enabled. + */ + private fun registerProxyPush() { + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { + Log.d(TAG, "Registering proxy push") + val pushUtils = PushUtils() + pushUtils.generateRsa2048KeyPair() + pushUtils.pushRegistrationToServer(ncApi) + } + } + + /** + * 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}") + 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 { + 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> { + 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 Observable.empty() + } + 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) + } + } + + 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 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 + } + } + } + } + + 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 + * + * 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( + 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!!)) + .flatMap { ocs -> + ocs.ocs?.data?.vapid?.let { vapid -> + UnifiedPush.register( + applicationContext, + instance = UnifiedPushUtils.instanceFor(user), + messageForDistributor = user.userId, + vapid = vapid + ) + Observable.just(user to true) + } ?: let { + Log.d(TAG, "No VAPID key found") + Observable.just(user to false) + } + } + } 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 USER_ID = "user_id" + 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/models/json/capabilities/NotificationsCapability.kt b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt index 957abe921e3..1f2d7d7f193 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/capabilities/NotificationsCapability.kt @@ -18,8 +18,10 @@ import kotlinx.serialization.Serializable @Serializable data class NotificationsCapability( @JsonField(name = ["ocs-endpoints"]) - var features: List? + var features: List?, + @JsonField(name = ["push"]) + var push: List? ) : Parcelable { // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' - constructor() : this(null) + constructor() : this(null, null) } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt index e3bb3cc74ec..6b519de312c 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/DecryptedPushMessage.kt @@ -46,7 +46,11 @@ data class DecryptedPushMessage( @JsonIgnore var notificationUser: NotificationUser?, - @JsonIgnore + /** + * /!\ It is overridden by common NC notifications, just used + * for internal notifications + */ + @JsonField(name = ["text"]) var text: String?, @JsonIgnore diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt new file mode 100644 index 00000000000..51976632936 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/Vapid.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.push + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class Vapid( + @JsonField(name = ["vapid"]) + var vapid: String? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt new file mode 100644 index 00000000000..080516b5fc7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOCS.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.push + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import com.nextcloud.talk.models.json.generic.GenericMeta +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class VapidOCS( + @JsonField(name = ["meta"]) + var meta: GenericMeta?, + @JsonField(name = ["data"]) + var data: Vapid? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null, null) +} diff --git a/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt new file mode 100644 index 00000000000..247514db1ce --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/models/json/push/VapidOverall.kt @@ -0,0 +1,23 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2022 Andy Scherzinger + * SPDX-FileCopyrightText: 2017 Mario Danic + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.models.json.push + +import android.os.Parcelable +import com.bluelinelabs.logansquare.annotation.JsonField +import com.bluelinelabs.logansquare.annotation.JsonObject +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonObject +data class VapidOverall( + @JsonField(name = ["ocs"]) + var ocs: VapidOCS? +) : Parcelable { + // This constructor is added to work with the 'com.bluelinelabs.logansquare.annotation.JsonObject' + constructor() : this(null) +} diff --git a/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt new file mode 100644 index 00000000000..b21829b2b76 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/services/UnifiedPushService.kt @@ -0,0 +1,96 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * 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.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 +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 + 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) + } + } + + override fun onRegistrationFailed(reason: FailedReason, instance: String) { + 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) { + 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) { + 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/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 1de5203983a..43aa3b0bbc7 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 @@ -100,6 +101,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody +import org.unifiedpush.android.connector.UnifiedPush import retrofit2.HttpException import java.net.URI import java.net.URISyntaxException @@ -308,16 +310,80 @@ class SettingsActivity : } private fun setupNotificationSettings() { + setupUnifiedPushSettings() setupNotificationSoundsSettings() setupNotificationPermissionSettings() setupServerNotificationAppCheck() } + private fun showUnifiedPushToggle(): Boolean { + return UnifiedPushUtils.getExternalDistributors(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. + // + // 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 (!showUnifiedPushToggle()) { + binding.settingsUnifiedpush.visibility = View.GONE + binding.settingsUnifiedpushService.visibility = View.GONE + } else { + val nDistrib = UnifiedPushUtils.getExternalDistributors(context).size + binding.settingsUnifiedpush.visibility = View.VISIBLE + binding.settingsUnifiedpushSwitch.isChecked = appPreferences.useUnifiedPush + binding.settingsUnifiedpush.setOnClickListener { + val checked = !appPreferences.useUnifiedPush + appPreferences.useUnifiedPush = checked + binding.settingsUnifiedpushSwitch.isChecked = checked + setupNotificationPermissionSettings() + setupUnifiedPushServiceSelectionVisibility(nDistrib) + if (checked) { + UnifiedPushUtils.useDefaultDistributor(this) { distrib -> + Log.d(TAG, "Registered to $distrib") + 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 + } + } + @SuppressLint("StringFormatInvalid") @Suppress("LongMethod") private fun setupNotificationPermissionSettings() { - if (ClosedInterfaceImpl().isGooglePlayServicesAvailable) { - binding.settingsGplayOnlyWrapper.visibility = View.VISIBLE + if (ClosedInterfaceImpl().isGooglePlayServicesAvailable || + appPreferences.useUnifiedPush || + UnifiedPushUtils.hasEmbeddedDistributor(context)) { + binding.settingsPushOnlyWrapper.visibility = View.VISIBLE + binding.settingsGplayNotAvailable.visibility = View.GONE + binding.settingsPushNotAvailable.visibility = View.GONE setTroubleshootingClickListenersIfNecessary() @@ -399,8 +465,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 + } } } @@ -772,6 +847,7 @@ class SettingsActivity : binding.run { listOf( settingsShowNotificationWarningSwitch, + settingsUnifiedpushSwitch, settingsScreenLockSwitch, settingsScreenSecuritySwitch, settingsIncognitoKeyboardSwitch, diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt new file mode 100644 index 00000000000..25648eb508e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/IntroduceUnifiedPushDialog.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.dialog; + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable; +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.nextcloud.talk.R + +@Composable +fun IntroduceUnifiedPushDialog( + onResponse: (Boolean) -> Unit +) { + var showDialog by remember { mutableStateOf(true) } + if (showDialog) { + AlertDialog( + confirmButton = { + TextButton(onClick = { + onResponse(true) + showDialog = false + }) { + Text(stringResource(android.R.string.ok)) + } + }, + onDismissRequest = { + onResponse(false) + showDialog = false + }, + dismissButton = { + TextButton(onClick = { + onResponse(false) + showDialog = false + }) { + Text(stringResource(android.R.string.cancel)) + } + }, + title = { + Text(stringResource(R.string.unifiedpush)) + }, + text = { + Text(stringResource(R.string.nc_dialog_introduce_unifiedpush_selection)) + }, + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index 5a06092c374..f7f7a6f1f04 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -393,6 +393,13 @@ object ApiUtils { } @JvmStatic + fun getUrlForVapid(baseUrl: String): String = "$baseUrl$OCS_API_VERSION/apps/notifications/api/v2/webpush/vapid" + @JvmStatic + fun getUrlForWebPush(baseUrl: String): String = "$baseUrl$OCS_API_VERSION/apps/notifications/api/v2/webpush" + @JvmStatic + fun getUrlForWebPushActivation(baseUrl: String): String = + "$baseUrl$OCS_API_VERSION/apps/notifications/api/v2/webpush/activate" + @JvmStatic fun getUrlNextcloudPush(baseUrl: String): String = "$baseUrl$OCS_API_VERSION/apps/notifications/api/v2/push" @JvmStatic diff --git a/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt index 95e59b21481..bf0d53aeaa2 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/PushUtils.kt @@ -221,6 +221,7 @@ class PushUtils { user: User ) { val credentials = ApiUtils.getCredentials(user.username, user.token) + Log.d(TAG, "Registering proxy push with ${user.userId}'s server.") ncApi.registerDeviceForNotificationsWithNextcloud( credentials, ApiUtils.getUrlNextcloudPush(user.baseUrl!!), diff --git a/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt new file mode 100644 index 00000000000..d7ebdd031a5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/UnifiedPushUtils.kt @@ -0,0 +1,197 @@ +/* + * 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.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 org.unifiedpush.android.connector.data.ResolvedDistributor +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 + * + * 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.tryUseDefaultDistributor(activity) { res -> + if (res) { + enqueuePushWorker(activity, true, "useDefaultDistributor") + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + callback(null) + } + } + } + + /** + * 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") + } + + /** + * 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, "pickDistributor") + callback(UnifiedPush.getSavedDistributor(activity)) + } else { + callback(null) + } + } + } + + /** + * Disable UnifiedPush and try to register with proxy push again + */ + @JvmStatic + fun disableExternalUnifiedPush( + context: Context + ) { + UnifiedPush.unregister(context) + enqueuePushWorker(context, false, "disableExternalUnifiedPush") + } + + /** + * Check if we have a FCM embedded distributor, to get push notifications, + * using the Play services, using Web Push + * + * Available on the generic flavor only + */ + @JvmStatic + fun hasEmbeddedDistributor(context: Context) = + context.packageName in UnifiedPush.getDistributors(context) + + @JvmStatic + fun useEmbeddedDistributor(context: Context) = + UnifiedPush.saveDistributor(context, context.packageName) + + @JvmStatic + fun getExternalDistributors(context: Context) = + UnifiedPush.getDistributors(context).filter { + it != context.packageName + } + + private fun enqueuePushWorker(context: Context, useUnifiedPush: Boolean, origin: String) { + val data = Data.Builder() + .putString(PushRegistrationWorker.ORIGIN, "UnifiedPushUtils#$origin") + .putBoolean(PushRegistrationWorker.USE_UNIFIEDPUSH, useUnifiedPush) + .build() + val pushRegistrationWork = OneTimeWorkRequest.Builder(PushRegistrationWorker::class.java) + .setInputData(data) + .build() + 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 + * + * 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() + } + } +} 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 d31b7c6e388..cd4135dbdd5 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" 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 43f0e5e9651..32d702ba036 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,14 @@ public interface AppPreferences { void removePushToken(); + boolean getUseUnifiedPush(); + + 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 adc8fb1f35e..2739cd80385 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,30 @@ 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 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() } @@ -599,6 +623,8 @@ 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 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/layout/activity_account_verification.xml b/app/src/main/res/layout/activity_account_verification.xml index 4e64db5ed16..8e3808b927d 100644 --- a/app/src/main/res/layout/activity_account_verification.xml +++ b/app/src/main/res/layout/activity_account_verification.xml @@ -43,4 +43,9 @@ android:textColor="@color/fg_default" tools:text="Verifying..." /> + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index a3191b81575..38334e8a826 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -205,7 +205,70 @@ android:textStyle="bold"/> + + + + + + + + + + + + + + + + + + + + + + @@ -291,6 +354,20 @@ android:text="@string/nc_diagnose_gplay_available_no"/> + + + + 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 @@ -129,6 +131,10 @@ How to translate with transifex: Light Dark Privacy + 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 @@ -205,7 +211,16 @@ How to translate with transifex: Registered users Google Play services Google Play services are available + Google Play services are not available. + UnifiedPush services + %d service(s) available + Offer UnifiedPush + Use UnifiedPush + UnifiedPush service + Latest endpoint received + Server supports webpush? Google Play services are not available. Notifications are not supported + UnifiedPush is disabled and Google Play services are not available. Notifications are not supported Battery settings Battery optimization is enabled which might cause issues. You should disable battery optimization! Battery optimization is ignored, all fine diff --git a/build.gradle b/build.gradle index 06510ba77e3..6fddc5f5939 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,12 @@ allprojects { repositories { google() mavenCentral() - maven { url = 'https://jitpack.io' } + maven { + url = 'https://jitpack.io' + content { + includeGroupByRegex("com\\.github\\..*") + } + } } } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index ad2df26d837..97833452492 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -225,6 +225,7 @@ + @@ -235,6 +236,10 @@ + + + +