From 8684bceca5672433cadd4fc8ac8a9a535eae95af Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 25 Mar 2023 19:20:59 +0100 Subject: [PATCH] UnifiedPush --- app/build.gradle.kts | 10 + app/proguard/proguard.cfg | 1 + app/src/main/AndroidManifest.xml | 12 ++ .../securesms/ApplicationContext.java | 8 +- .../thoughtcrime/securesms/MainActivity.java | 3 + .../app/internal/InternalSettingsFragment.kt | 16 ++ .../NotificationsSettingsFragment.kt | 33 ++++ .../NotificationsSettingsState.kt | 3 +- .../NotificationsSettingsViewModel.kt | 1 + .../securesms/gcm/FcmReceiveService.java | 3 +- .../securesms/jobs/JobManagerFactories.java | 3 + .../securesms/keyvalue/AccountValues.kt | 2 +- .../securesms/keyvalue/SettingsValues.java | 3 +- .../securesms/keyvalue/SignalStore.kt | 6 + .../logsubmit/LogSectionSystemInfo.java | 1 + .../data/RegistrationRepository.kt | 1 - .../app_settings_with_change_number.xml | 13 ++ app/src/main/res/values/strings2.xml | 40 ++++ .../UnifiedPushSettingsFragment.kt | 175 ++++++++++++++++++ .../notifications/UnifiedPushSettingsState.kt | 20 ++ .../UnifiedPushSettingsViewModel.kt | 127 +++++++++++++ .../device/MollySocketLinkedDevice.kt | 122 ++++++++++++ .../unifiedpush/jobs/UnifiedPushRefreshJob.kt | 166 +++++++++++++++++ .../unifiedpush/model/MollySocketDevice.kt | 7 + .../unifiedpush/model/RegistrationStatus.kt | 49 +++++ .../unifiedpush/model/UnifiedPushStatus.kt | 18 ++ .../receiver/UnifiedPushReceiver.kt | 95 ++++++++++ .../unifiedpush/util/MollySocketRequest.kt | 109 +++++++++++ .../unifiedpush/util/UnifiedPushHelper.kt | 51 +++++ .../util/UnifiedPushNotificationBuilder.kt | 56 ++++++ .../securesms/keyvalue/UnifiedPushValues.kt | 88 +++++++++ dependencies.gradle.kts | 4 + gradle/verification-metadata.xml | 29 +++ 33 files changed, 1269 insertions(+), 6 deletions(-) create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/device/MollySocketLinkedDevice.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/model/RegistrationStatus.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/model/UnifiedPushStatus.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/util/MollySocketRequest.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/util/UnifiedPushHelper.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/util/UnifiedPushNotificationBuilder.kt create mode 100644 app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 659545b727..4210a09c29 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,6 +122,10 @@ android { getByName("androidTest") { java.srcDir("$projectDir/src/testShared") } + + getByName("main") { + java.srcDir("$projectDir/src/unifiedpush/java") + } } compileOptions { @@ -530,6 +534,12 @@ dependencies { implementation(libs.molly.glide.webp.decoder) implementation(libs.gosimple.nbvcxz) "fossImplementation"("org.osmdroid:osmdroid-android:6.1.16") + implementation(libs.unifiedpush.connector) { + exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib") + } + implementation(libs.unifiedpush.connector.ui) { + exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib") + } "gmsImplementation"(project(":billing")) diff --git a/app/proguard/proguard.cfg b/app/proguard/proguard.cfg index d52eb11852..cdc423eb30 100644 --- a/app/proguard/proguard.cfg +++ b/app/proguard/proguard.cfg @@ -1,6 +1,7 @@ -dontobfuscate -keepattributes SourceFile,LineNumberTable -keep class org.whispersystems.** { *; } +-keep class im.molly.** { *; } -keep class org.signal.libsignal.net.** { *; } -keep class org.signal.libsignal.protocol.** { *; } -keep class org.signal.libsignal.usernames.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a0caa36f06..b0ded4dd37 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1316,6 +1316,18 @@ + + + + + + + + + + SignalStore.unifiedpush.pinged = false + Toast.makeText(requireContext(), "UnifiedPush ping deleted!", Toast.LENGTH_SHORT).show() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + ) + dividerPref() sectionHeaderPref(DSLSettingsText.from("Logging")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsFragment.kt index 0dd3e7b4c3..ccda68d6fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsFragment.kt @@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.notifications.NotificationChannels import org.thoughtcrime.securesms.notifications.TurnOnNotificationsBottomSheet import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.PlayServicesUtil import org.thoughtcrime.securesms.util.RingtoneUtil import org.thoughtcrime.securesms.util.SecurePreferenceManager @@ -347,6 +348,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__ val showAlertIcon = when (state.preferredNotificationMethod) { NotificationDeliveryMethod.FCM -> !state.canReceiveFcm NotificationDeliveryMethod.WEBSOCKET -> false + NotificationDeliveryMethod.UNIFIEDPUSH -> !state.canReceiveUnifiedPush } radioListPref( title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__delivery_method), @@ -358,6 +360,14 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__ onNotificationMethodChanged(notificationMethodValues[it], state.preferredNotificationMethod) } ) + + clickPref( + title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__configure_unifiedpush), + isEnabled = state.preferredNotificationMethod == NotificationDeliveryMethod.UNIFIEDPUSH, + onClick = { + navigateToUnifiedPushSettings() + } + ) } } @@ -383,9 +393,32 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__ NotificationDeliveryMethod.WEBSOCKET -> { viewModel.setPreferredNotificationMethod(method) } + + NotificationDeliveryMethod.UNIFIEDPUSH -> { + if (method != previousMethod) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.NotificationsSettingsFragment__mollysocket_server) + .setMessage(R.string.NotificationsSettingsFragment__to_use_unifiedpush_you_need_a_mollysocket_server) + .setPositiveButton(R.string.yes) { _, _ -> + viewModel.setPreferredNotificationMethod(method) + navigateToUnifiedPushSettings() + } + .setNegativeButton(R.string.no, null) + .setNeutralButton(R.string.LearnMoreTextView_learn_more) { _, _ -> + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.mollysocket_setup_url)) + } + .show() + } else { + navigateToUnifiedPushSettings() + } + } } } + private fun navigateToUnifiedPushSettings() { + findNavController().safeNavigate(R.id.action_notificationsSettingsFragment_to_unifiedPushFragment) + } + private fun getRingtoneSummary(uri: Uri): String { return if (TextUtils.isEmpty(uri.toString())) { getString(R.string.preferences__silent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsState.kt index 24f958ce9f..11e4a3b3cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsState.kt @@ -11,7 +11,8 @@ data class NotificationsSettingsState( val notifyWhenContactJoinsSignal: Boolean, val isLinkedDevice: Boolean, val preferredNotificationMethod: NotificationDeliveryMethod, - val canReceiveFcm: Boolean + val canReceiveFcm: Boolean, + val canReceiveUnifiedPush: Boolean ) data class MessageNotificationsState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsViewModel.kt index 7c7996a281..21f2243d32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/NotificationsSettingsViewModel.kt @@ -159,6 +159,7 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer isLinkedDevice = SignalStore.account.isLinkedDevice, preferredNotificationMethod = SignalStore.settings.preferredNotificationMethod, canReceiveFcm = SignalStore.account.canReceiveFcm, + canReceiveUnifiedPush = SignalStore.unifiedpush.isAvailableOrAirGapped ) private fun canEnableNotifications(): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java index 069e02cbac..d7d502ec06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/gcm/FcmReceiveService.java @@ -95,7 +95,8 @@ public void onSendError(@NonNull String s, @NonNull Exception e) { Log.w(TAG, "onSendError()", e); } - private static void handleReceivedNotification(Context context, @Nullable RemoteMessage remoteMessage) { + // MOLLY: Make this function public to use it from UnifiedPushReceiver + public static void handleReceivedNotification(Context context, @Nullable RemoteMessage remoteMessage) { boolean highPriority = remoteMessage != null && remoteMessage.getPriority() == RemoteMessage.PRIORITY_HIGH; try { Log.d(TAG, String.format(Locale.US, "[handleReceivedNotification] API: %s, RemoteMessagePriority: %s", Build.VERSION.SDK_INT, remoteMessage != null ? remoteMessage.getPriority() : "n/a")); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 286ab7ee24..b5fd9c5faa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -96,6 +96,8 @@ import java.util.List; import java.util.Map; +import im.molly.unifiedpush.jobs.UnifiedPushRefreshJob; + public final class JobManagerFactories { public static Map getJobFactories(@NonNull Application application) { @@ -140,6 +142,7 @@ public static Map getJobFactories(@NonNull Application appl put(DownloadLatestEmojiDataJob.KEY, new DownloadLatestEmojiDataJob.Factory()); put(EmojiSearchIndexDownloadJob.KEY, new EmojiSearchIndexDownloadJob.Factory()); put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory()); + put(UnifiedPushRefreshJob.KEY, new UnifiedPushRefreshJob.Factory()); put(FetchRemoteMegaphoneImageJob.KEY, new FetchRemoteMegaphoneImageJob.Factory()); put(FontDownloaderJob.KEY, new FontDownloaderJob.Factory()); put(ForceUpdateGroupV2Job.KEY, new ForceUpdateGroupV2Job.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index 83013d578f..ebaa073296 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -317,7 +317,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) @get:JvmName("isPushAvailable") val pushAvailable: Boolean - get() = canReceiveFcm + get() = canReceiveFcm || SignalStore.unifiedpush.isAvailableOrAirGapped /** The FCM token, which allows the server to send us FCM messages. */ var fcmToken: String? diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java index 993915d4dc..9029166898 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java @@ -551,7 +551,7 @@ public enum Theme { } public enum NotificationDeliveryMethod { - FCM, WEBSOCKET; + FCM, WEBSOCKET, UNIFIEDPUSH; public @NonNull String serialize() { return name(); @@ -565,6 +565,7 @@ public enum NotificationDeliveryMethod { return switch (this) { case FCM -> R.string.NotificationDeliveryMethod__fcm; case WEBSOCKET -> R.string.NotificationDeliveryMethod__websocket; + case UNIFIEDPUSH -> R.string.NotificationDeliveryMethod__unifiedpush; }; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt index 0fe6f008c7..55a604d98e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt @@ -37,6 +37,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) { val storyValues = StoryValues(store) val apkUpdateValues = ApkUpdateValues(store) val backupValues = BackupValues(store) + val unifiedPushValues = UnifiedPushValues(store) val plainTextValues = PlainTextSharedPrefsDataStore(context) @@ -258,6 +259,11 @@ class SignalStore(context: Application, private val store: KeyValueStore) { val backup: BackupValues get() = instance!!.backupValues + @JvmStatic + @get:JvmName("unifiedpush") + val unifiedpush: UnifiedPushValues + get() = instance!!.unifiedPushValues + val groupsV2AciAuthorizationCache: GroupsV2AuthorizationSignalStoreCache get() = GroupsV2AuthorizationSignalStoreCache.createAciCache(instance!!.store) diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionSystemInfo.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionSystemInfo.java index b4dc9913b4..a039d41497 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionSystemInfo.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionSystemInfo.java @@ -81,6 +81,7 @@ public class LogSectionSystemInfo implements LogSection { builder.append("Network Status : ").append(NetworkUtil.getNetworkStatus(context)).append("\n"); builder.append("Play Services : ").append(getPlayServicesString(context)).append("\n"); builder.append("FCM : ").append(locked ? "Unknown" : SignalStore.account().isFcmEnabled()).append("\n"); + builder.append("UnifiedPush : ").append(locked ? "Unknown" : SignalStore.unifiedpush().isEnabled()).append("\n"); builder.append("Locale : ").append(Locale.getDefault()).append("\n"); builder.append("Linked Devices : ").append(locked ? "Unknown" : TextSecurePreferences.isMultiDevice(context)).append("\n"); builder.append("First Version : ").append(TextSecurePreferences.getFirstInstallVersion(context)).append("\n"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt index 451327f9a5..c8e803bcb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt @@ -587,7 +587,6 @@ object RegistrationRepository { return started == true } - @VisibleForTesting fun generateSignedAndLastResortPreKeys(identity: IdentityKeyPair, metadataStore: PreKeyMetadataStore): PreKeyCollection { val signedPreKey = PreKeyUtil.generateSignedPreKey(metadataStore.nextSignedPreKeyId, identity.privateKey) val lastResortKyberPreKey = PreKeyUtil.generateLastResortKyberPreKey(metadataStore.nextKyberPreKeyId, identity.privateKey) diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 34a3824f41..ccf8771abe 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -396,6 +396,14 @@ app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + @@ -803,6 +811,11 @@ app:popExitAnim="@anim/fragment_close_exit" /> + + + https://molly.im + https://github.com/mollyim/mollysocket Error If you go a certain time without unlocking your device None @@ -127,6 +128,45 @@ Push notifications Preferred method for receiving notifications from the Signal service. If your selected method is unavailable, the app will automatically switch to WebSocket. Delivery method + Configure UnifiedPush + To use UnifiedPush, you need a MollySocket server to link your Signal account. Do you have access to a MollySocket server? + MollySocket server + Not selected + None available + Enabled + Notification method + Status + Click to copy + Air gapped + Server parameters + Account ID + Unknown + Server URL + Please provide the MollySocket server URL + No device + No endpoint + Disabled + OK: Air Gapped + MollySocket url missing + Pending + Waiting for test notification + Air Gapped, waiting for test notification + MollySocket server not found + An internal Error occurred, please try again + The account ID is refused by the server + Cannot create the linked device. + Unable to find the endpoint + The endpoint is forbidden by the server + No UnifiedPush distributor installed + Unknown error + Enable if your MollySocket server can\'t be reach on the Internet. You will have to register the connection manually + You have reached the limit of allowed linked devices: we can\'t link your MollySocket server. Please remove a linked device and try again. + Your registration on MollySocket isn\'t valid anymore. Try to remove the linked device and register again. + Your UnifiedPush endpoint has changed. You must update your connection on MollySocket. + An error occurred while changing your UnifiedPush endpoint. Try to register again to MollySocket. + Your UnifiedPush distributor refused the registration. You may not have any connection or a requirement is missing for your distributor. + This is a test notification received from your MollySocket server. + UnifiedPush Google Services (FCM) WebSocket diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt new file mode 100644 index 0000000000..95cd2c760f --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt @@ -0,0 +1,175 @@ +package im.molly.unifiedpush.components.settings.app.notifications + +import android.content.DialogInterface +import android.content.res.Resources +import android.text.InputType +import android.widget.EditText +import android.widget.FrameLayout +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import im.molly.unifiedpush.model.UnifiedPushStatus +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.DSLConfiguration +import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment +import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon +import org.thoughtcrime.securesms.components.settings.DSLSettingsText +import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle +import org.thoughtcrime.securesms.events.PushServiceEvent +import org.thoughtcrime.securesms.util.Util.writeTextToClipboard +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter + +class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDeliveryMethod__unifiedpush) { + + private lateinit var viewModel: UnifiedPushSettingsViewModel + + override fun bindAdapter(adapter: MappingAdapter) { + val factory = UnifiedPushSettingsViewModel.Factory(requireActivity().application) + + viewModel = ViewModelProvider(this, factory)[UnifiedPushSettingsViewModel::class.java] + + viewModel.state.observe(viewLifecycleOwner) { + adapter.submitList(getConfiguration(it).toMappingModelList()) + } + + EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = viewLifecycleOwner) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onPushServiceEvent(event: PushServiceEvent) { + viewModel.refresh() + } + + private fun getConfiguration(state: UnifiedPushSettingsState): DSLConfiguration { + return configure { + textPref( + title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__status)), + summary = DSLSettingsText.from(getStatusSummary(state)), + ) + + radioListPref( + title = DSLSettingsText.from(R.string.UnifiedPushSettingsFragment__method), + listItems = state.distributors.map { it.name }.toTypedArray(), + selected = state.selected, + onSelected = { + viewModel.setUnifiedPushDistributor(state.distributors[it].applicationId) + }, + ) + + dividerPref() + + switchPref( + title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__air_gapped)), + summary = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__air_gapped_summary)), + isChecked = state.airGapped, + onClick = { + viewModel.setUnifiedPushAirGapped(!state.airGapped) + } + ) + + if (state.airGapped) { + + clickPref( + title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__server_parameters)), + summary = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__click_to_copy)), + iconEnd = DSLSettingsIcon.from(R.drawable.symbol_copy_android_24), + onClick = { writeTextToClipboard(requireContext(), "Server parameters", getServerParameters(state)) }, + ) + } else { + + clickPref( + title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__account_id)), + summary = DSLSettingsText.from(state.device?.uuid ?: getString(R.string.UnifiedPushSettingsFragment__unknown)), + iconEnd = DSLSettingsIcon.from(R.drawable.symbol_copy_android_24), + onClick = { + writeTextToClipboard(requireContext(), "Account ID", state.device?.uuid ?: getString(R.string.UnifiedPushSettingsFragment__unknown)) + }, + ) + + clickPref( + title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__server_url)), + summary = DSLSettingsText.from(state.mollySocketUrl ?: getString(R.string.UnifiedPushSettingsFragment__no_server_url_summary)), + iconEnd = getMollySocketUrlIcon(state), + onClick = { urlDialog(state) }, + ) + } + } + } + + private fun getServerParameters(state: UnifiedPushSettingsState): String { + val device = state.device ?: return getString(R.string.UnifiedPushSettingsFragment__no_device) + val endpoint = state.endpoint ?: return getString(R.string.UnifiedPushSettingsFragment__no_endpoint) + return "connection add ${device.uuid} ${device.deviceId} ${device.password} $endpoint" + } + + private fun urlDialog(state: UnifiedPushSettingsState) { + val alertDialog = MaterialAlertDialogBuilder(requireContext()) + val input = EditText(requireContext()).apply { + inputType = InputType.TYPE_TEXT_VARIATION_URI + setText(state.mollySocketUrl) + } + alertDialog.setEditText( + input + ) + alertDialog.setPositiveButton(getString(android.R.string.ok)) { _: DialogInterface, _: Int -> + viewModel.setMollySocketUrl(input.text.toString()) + } + alertDialog.show() + } + + private val Float.toPx: Int + get() = (this * Resources.getSystem().displayMetrics.density).toInt() + + private fun MaterialAlertDialogBuilder.setEditText(editText: EditText): MaterialAlertDialogBuilder { + val container = FrameLayout(context) + container.addView(editText) + val containerParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + val marginHorizontal = 48F + val marginTop = 16F + containerParams.topMargin = (marginTop / 2).toPx + containerParams.leftMargin = marginHorizontal.toInt() + containerParams.rightMargin = marginHorizontal.toInt() + container.layoutParams = containerParams + + val superContainer = FrameLayout(context) + superContainer.addView(container) + + setView(superContainer) + + return this + } + + private fun getStatusSummary(state: UnifiedPushSettingsState): String { + return when (state.status) { + UnifiedPushStatus.DISABLED -> getString(R.string.UnifiedPushSettingsFragment__status_summary_disabled) + UnifiedPushStatus.LINK_DEVICE_ERROR -> getString(R.string.UnifiedPushSettingsFragment__status_summary_linked_device_error) + UnifiedPushStatus.AIR_GAPPED -> getString(R.string.UnifiedPushSettingsFragment__status_summary_air_gapped) + UnifiedPushStatus.SERVER_NOT_FOUND_AT_URL -> getString(R.string.UnifiedPushSettingsFragment__status_summary_mollysocket_server_not_found) + UnifiedPushStatus.MISSING_ENDPOINT -> getString(R.string.UnifiedPushSettingsFragment__status_summary_missing_endpoint) + UnifiedPushStatus.FORBIDDEN_UUID -> getString(R.string.UnifiedPushSettingsFragment__status_summary_forbidden_uuid) + UnifiedPushStatus.FORBIDDEN_ENDPOINT -> getString(R.string.UnifiedPushSettingsFragment__status_summary_forbidden_endpoint) + UnifiedPushStatus.NO_DISTRIBUTOR -> getString(R.string.UnifiedPushSettingsFragment__status_summary_no_distributor) + UnifiedPushStatus.PENDING -> getString(R.string.UnifiedPushSettingsFragment__status_summary_pending) + UnifiedPushStatus.AIR_GAPPED_NOT_PINGED -> getString(R.string.UnifiedPushSettingsFragment__status_summary_air_gapped_not_pinged) + UnifiedPushStatus.NOT_PINGED -> getString(R.string.UnifiedPushSettingsFragment__status_summary_not_pinged) + UnifiedPushStatus.OK -> getString(android.R.string.ok) + UnifiedPushStatus.INTERNAL_ERROR -> getString(R.string.UnifiedPushSettingsFragment__status_summary_internal_error) + UnifiedPushStatus.UNKNOWN -> getString(R.string.UnifiedPushSettingsFragment__status_summary_unknown_error) + } + } + + private fun getMollySocketUrlIcon(state: UnifiedPushSettingsState): DSLSettingsIcon? { + if (state.mollySocketUrl.isNullOrBlank() || state.status == UnifiedPushStatus.PENDING) return null + return if (state.mollySocketOk) { + DSLSettingsIcon.from(R.drawable.ic_check_20) + } else { + DSLSettingsIcon.from(R.drawable.ic_alert) + } + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt new file mode 100644 index 0000000000..21999de1b1 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt @@ -0,0 +1,20 @@ +package im.molly.unifiedpush.components.settings.app.notifications + +import im.molly.unifiedpush.model.MollySocketDevice +import im.molly.unifiedpush.model.UnifiedPushStatus + +data class Distributor( + val applicationId: String, + val name: String, +) + +data class UnifiedPushSettingsState( + val airGapped: Boolean, + val device: MollySocketDevice?, + val distributors: List, + val selected: Int, + val endpoint: String?, + val mollySocketUrl: String?, + val mollySocketOk: Boolean, + var status: UnifiedPushStatus, +) diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt new file mode 100644 index 0000000000..7a44654e05 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt @@ -0,0 +1,127 @@ +package im.molly.unifiedpush.components.settings.app.notifications + +import android.app.Application +import android.content.pm.PackageManager +import android.os.Build +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import im.molly.unifiedpush.jobs.UnifiedPushRefreshJob +import im.molly.unifiedpush.model.UnifiedPushStatus +import im.molly.unifiedpush.util.MollySocketRequest +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor +import org.thoughtcrime.securesms.util.livedata.Store +import org.unifiedpush.android.connector.UnifiedPush + +class UnifiedPushSettingsViewModel(private val application: Application) : ViewModel() { + + private val TAG = Log.tag(UnifiedPushSettingsViewModel::class.java) + private val store = Store(getState()) + private var status: UnifiedPushStatus? = null + private val EXECUTOR = SerialMonoLifoExecutor(SignalExecutors.UNBOUNDED) + + val state: LiveData = store.stateLiveData + + fun refresh() { + store.update { getState() } + } + + private fun getState(): UnifiedPushSettingsState { + status ?: run { status = SignalStore.unifiedpush.status } + val distributor = UnifiedPush.getAckDistributor(application) + var count = -1 + var selected = -1 + + var distributors = UnifiedPush.getDistributors(application).map { + count++ + if (it == distributor) { + selected = count + } + Distributor( + applicationId = it, + name = try { + val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + application.packageManager.getApplicationInfo(it, + PackageManager.ApplicationInfoFlags.of( + PackageManager.GET_META_DATA.toLong() + ) + ) + } else { + application.packageManager.getApplicationInfo(it, 0) + } + application.packageManager.getApplicationLabel(ai) + } catch (e: PackageManager.NameNotFoundException) { + it + } as String + ) + } + + if (distributors.isEmpty()) { + distributors = listOf( + Distributor( + name = application.getString(R.string.UnifiedPushSettingsViewModel__no_distributor), + applicationId = "", + ) + ) + status = UnifiedPushStatus.NO_DISTRIBUTOR + } + + return UnifiedPushSettingsState( + airGapped = SignalStore.unifiedpush.airGapped, + device = SignalStore.unifiedpush.device, + distributors = distributors, + selected = selected, + endpoint = SignalStore.unifiedpush.endpoint, + mollySocketUrl = SignalStore.unifiedpush.mollySocketUrl, + mollySocketOk = SignalStore.unifiedpush.mollySocketFound, + status = status ?: SignalStore.unifiedpush.status, + ) + } + + fun setUnifiedPushAirGapped(airGapped: Boolean) { + SignalStore.unifiedpush.airGapped = airGapped + processNewStatus() + } + + fun setUnifiedPushDistributor(distributor: String) { + UnifiedPush.saveDistributor(application, distributor) + UnifiedPush.registerApp(application) + refresh() + } + + fun setMollySocketUrl(url: String?) { + SignalStore.unifiedpush.mollySocketUrl = if (url.isNullOrBlank()) { + null + } else if (url.last() != '/') { + "$url/" + } else { + url + } + SignalStore.unifiedpush.pending = true + EXECUTOR.enqueue { + SignalStore.unifiedpush.mollySocketFound = try { + MollySocketRequest.discoverMollySocketServer() + } catch (e: Exception) { + SignalStore.unifiedpush.mollySocketInternalError = true + false + } + processNewStatus() + } + } + + private fun processNewStatus() { + refresh() + AppDependencies.jobManager.add(UnifiedPushRefreshJob()) + } + + class Factory(private val application: Application) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(UnifiedPushSettingsViewModel(application))) + } + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/device/MollySocketLinkedDevice.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/device/MollySocketLinkedDevice.kt new file mode 100644 index 0000000000..67d90a38a1 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/device/MollySocketLinkedDevice.kt @@ -0,0 +1,122 @@ +package im.molly.unifiedpush.device + +import android.content.Context +import im.molly.unifiedpush.model.MollySocketDevice +import im.molly.unifiedpush.util.UnifiedPushNotificationBuilder +import org.signal.core.util.Base64 +import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.util.KeyHelper +import org.thoughtcrime.securesms.AppCapabilities +import org.thoughtcrime.securesms.database.loaders.DeviceListLoader +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.devicelist.Device +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.push.AccountManagerFactory +import org.thoughtcrime.securesms.registration.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.thoughtcrime.securesms.util.Util +import org.whispersystems.signalservice.api.account.AccountAttributes +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException +import java.io.IOException +import java.nio.charset.Charset + +class MollySocketLinkedDevice(val context: Context) { + private val TAG = Log.tag(MollySocketLinkedDevice::class.java) + private val DEVICE_NAME = "MollySocket" + + var device: MollySocketDevice? = null + + init { + if (isDeviceLinked() == false) { + // If we previously had a linked device, it is no longer registered: + // we remove information about this potential previous device + Log.d(TAG, "MollySocketDevice is not present") + SignalStore.unifiedpush.device = null + } + device = SignalStore.unifiedpush.device + ?: run { + SignalStore.unifiedpush.pinged = false + newDevice() + SignalStore.unifiedpush.device + } + } + + private fun isDeviceLinked(): Boolean? { + val device = SignalStore.unifiedpush.device ?: return false + val devices: List? + try { + devices = DeviceListLoader(context, AppDependencies.signalServiceAccountManager).loadInBackground() + } catch (e: IOException) { + Log.e(TAG, "Encountered an IOException", e) + return null + } + devices?.forEach { it_device -> + if (it_device.id.toInt() == device.deviceId && it_device.name == DEVICE_NAME) { + return true + } + } + return false + } + + private fun newDevice() { + Log.d(TAG, "Creating a device for MollySocket") + try { + val number = SignalStore.account.e164 ?: return + val password = Util.getSecret(18) + + val deviceId = verifyNewDevice(number, password) + TextSecurePreferences.setMultiDevice(context, true) + + SignalStore.unifiedpush.device = MollySocketDevice( + uuid = SignalStore.account.aci.toString(), + deviceId = deviceId, + password = password + ) + } catch (e: DeviceLimitExceededException) { + Log.w(TAG, "The account already have 5 linked devices.") + UnifiedPushNotificationBuilder(context).setNotificationDeviceLimitExceeded() + } catch (e: IOException) { + Log.e(TAG, "Encountered an IOException", e) + } + } + + @Throws(IOException::class) + private fun verifyNewDevice(number: String, password: String): Int { + val verificationCode = AppDependencies.signalServiceAccountManager + .newDeviceVerificationCode + val registrationId = KeyHelper.generateRegistrationId(false) + val encryptedDeviceName = DeviceNameCipher.encryptDeviceName( + DEVICE_NAME.toByteArray(Charset.forName("UTF-8")), + SignalStore.account.aciIdentityKey + ) + val accountManager = AccountManagerFactory.getInstance().createUnauthenticated(context, number, SignalServiceAddress.DEFAULT_DEVICE_ID, password) + + val accountAttributes = AccountAttributes( + signalingKey = null, + registrationId = registrationId, + fetchesMessages = true, + registrationLock = null, + unidentifiedAccessKey = null, + unrestrictedUnidentifiedAccess = true, + capabilities = AppCapabilities.getCapabilities(true), + discoverableByPhoneNumber = + SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE, + name = Base64.encodeWithPadding(encryptedDeviceName), + pniRegistrationId = SignalStore.account.pniRegistrationId, + recoveryPassword = null + ) + + val aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys) + val pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.pniIdentityKey, SignalStore.account.pniPreKeys) + + return accountManager.finishNewDeviceRegistration( + verificationCode, + accountAttributes, + aciPreKeyCollection, pniPreKeyCollection, + null + ) + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt new file mode 100644 index 0000000000..ae96a7c048 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt @@ -0,0 +1,166 @@ + +package im.molly.unifiedpush.jobs + +import im.molly.unifiedpush.model.RegistrationStatus +import im.molly.unifiedpush.model.UnifiedPushStatus +import im.molly.unifiedpush.model.saveStatus +import im.molly.unifiedpush.util.MollySocketRequest +import im.molly.unifiedpush.util.UnifiedPushHelper +import im.molly.unifiedpush.util.UnifiedPushNotificationBuilder +import org.greenrobot.eventbus.EventBus +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.ApplicationContext +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.events.PushServiceEvent +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.BaseJob +import org.thoughtcrime.securesms.jobs.FcmRefreshJob +import org.thoughtcrime.securesms.keyvalue.SettingsValues +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException +import java.util.concurrent.TimeUnit + +/* +This job is called when : +- The delivery Method is called +- Delivery method == UnifiedPush and : + - The app starts + - one component related to UnifiedPush is changed + */ + +class UnifiedPushRefreshJob private constructor(parameters: Parameters) : BaseJob(parameters) { + constructor() : this( + Parameters.Builder() + .setQueue(FcmRefreshJob.QUEUE_KEY) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(3) + .setLifespan(TimeUnit.HOURS.toMillis(6)) + .setMaxInstancesForFactory(1) + .build() + ) + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + @Throws(Exception::class) + public override fun onRun() { + Log.d(TAG, "Running the refresh job") + + // TODO this job was skipped in ApplicationContext if airgapped + + // TODO from ViewModel + // + // val context = ApplicationContext.getInstance() + // if (method == NotificationDeliveryMethod.UNIFIEDPUSH) { + // SignalStore.unifiedpush.pending = true + // UnifiedPush.getDistributors(context).getOrNull(0)?.let { + // refresh() + // executor.enqueue { + // UnifiedPush.saveDistributor(context, it) + // UnifiedPush.registerApp(context) + // UnifiedPushHelper.initializeMollySocketLinkedDevice(context) + // AppDependencies.jobManager.add(UnifiedPushRefreshJob()) + // } + // // Do not enable if there is no distributor + // } ?: return + // } else { + // UnifiedPush.unregisterApp(context) + // SignalStore.unifiedpush.airGapped = false + // AppDependencies.jobManager.add(UnifiedPushRefreshJob()) + // ApplicationContext.getInstance().updatePushNotificationServices() + // } + + // If this job is called while changing the notification method + val currentMethod = SignalStore.settings.preferredNotificationMethod + if (currentMethod == SettingsValues.NotificationDeliveryMethod.FCM + || currentMethod == SettingsValues.NotificationDeliveryMethod.WEBSOCKET) { + Log.d(TAG, "New method: $currentMethod, reinitializing notification services.") + reInitializeNotificationServices() + return + } + + // Else : we try to use UnifiedPush + UnifiedPushHelper.checkDistributorPresence(context) + val status = SignalStore.unifiedpush.status + Log.d(TAG, "UnifiedPush Status: $status") + when (status) { + // Should not occur + UnifiedPushStatus.DISABLED, + UnifiedPushStatus.UNKNOWN -> Log.e(TAG, "UnifiedPush setup should not be in this state here : $status.") + // It will fallback on FCM/Websocket + UnifiedPushStatus.MISSING_ENDPOINT, + UnifiedPushStatus.NO_DISTRIBUTOR, + UnifiedPushStatus.LINK_DEVICE_ERROR, + UnifiedPushStatus.AIR_GAPPED_NOT_PINGED, + UnifiedPushStatus.SERVER_NOT_FOUND_AT_URL-> { + Log.i(TAG, "UnifiedPush enabled, but this is currently unavailable. Status=$status.") + reInitializeNotificationServices() + } + // Considered as successful setup + UnifiedPushStatus.AIR_GAPPED -> { + Log.i(TAG, "UnifiedPush available in AirGapped mode. No MollySocket to register to.") + reInitializeNotificationServices() + } + // We try to register to MollySocket server, + // Then re-init the services + UnifiedPushStatus.PENDING, + UnifiedPushStatus.NOT_PINGED, + UnifiedPushStatus.FORBIDDEN_UUID, + UnifiedPushStatus.FORBIDDEN_ENDPOINT, + UnifiedPushStatus.INTERNAL_ERROR -> { + Log.i(TAG, "Registering to MollySocket...") + SignalStore.unifiedpush.pending = false + val msStatus = MollySocketRequest.registerToMollySocketServer() + msStatus.saveStatus() + when (msStatus) { + RegistrationStatus.INTERNAL_ERROR -> Log.d(TAG, "An error occurred while trying to re-register with MollySocket.") + RegistrationStatus.OK -> { + Log.d(TAG, "Successfully re-registered to MollySocket") + reInitializeNotificationServices() + } + else -> Log.d(TAG, "Still not able to register to MollySocket: $msStatus.") + } + } + UnifiedPushStatus.OK -> { + Log.i(TAG, "Registering again to MollySocket...") + when (val msStatus = MollySocketRequest.registerToMollySocketServer()) { + RegistrationStatus.INTERNAL_ERROR -> Log.d(TAG, "An error occurred while trying to re-register with MollySocket. It may be a bad connection: ignore it.") + RegistrationStatus.OK -> Log.d(TAG, "Successfully re-registered to MollySocket") + else -> { + Log.w(TAG, "The registration status has changed!") + msStatus.saveStatus() + reInitializeNotificationServices() + UnifiedPushNotificationBuilder(context).setNotificationMollySocketRegistrationChanged() + } + } + } + } + EventBus.getDefault().post(PushServiceEvent) + } + + private fun reInitializeNotificationServices() { + ApplicationContext.getInstance().updatePushNotificationServices() + AppDependencies.resetNetwork(true) + } + + override fun onFailure() { + Log.w(TAG, "MollySocket reregistration failed after retry attempt exhaustion!") + } + + public override fun onShouldRetry(throwable: Exception): Boolean { + return throwable !is NonSuccessfulResponseCodeException + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): UnifiedPushRefreshJob { + return UnifiedPushRefreshJob(parameters) + } + } + + companion object { + const val KEY = "UnifiedPushRefreshJob" + private val TAG = Log.tag(UnifiedPushRefreshJob::class.java) + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt new file mode 100644 index 0000000000..9e7dfc58a4 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt @@ -0,0 +1,7 @@ +package im.molly.unifiedpush.model + +data class MollySocketDevice( + val uuid: String, + val deviceId: Int, + val password: String, +) diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/model/RegistrationStatus.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/model/RegistrationStatus.kt new file mode 100644 index 0000000000..6cf736f159 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/model/RegistrationStatus.kt @@ -0,0 +1,49 @@ +package im.molly.unifiedpush.model + +import org.thoughtcrime.securesms.keyvalue.SignalStore + +enum class RegistrationStatus(private val formatted: String) { + OK("ok"), + FORBIDDEN("forbidden"), + INVALID_UUID("invalid_uuid"), + INVALID_ENDPOINT("invalid_endpoint"), + INTERNAL_ERROR("internal_error"), + NO_DEVICE("_1"), + NO_ENDPOINT("_2"), + NO_MOLLYSOCKET("_3"); + + override fun toString(): String { + return formatted + } +} + +fun RegistrationStatus.saveStatus() { + when (this) { + RegistrationStatus.OK -> { + SignalStore.unifiedpush.forbiddenUuid = false + SignalStore.unifiedpush.mollySocketInternalError = false + SignalStore.unifiedpush.forbiddenEndpoint = false + } + RegistrationStatus.INVALID_UUID -> { + SignalStore.unifiedpush.forbiddenUuid = true + SignalStore.unifiedpush.mollySocketInternalError = false + SignalStore.unifiedpush.forbiddenEndpoint = false + } + RegistrationStatus.INVALID_ENDPOINT -> { + SignalStore.unifiedpush.forbiddenUuid = false + SignalStore.unifiedpush.mollySocketInternalError = false + SignalStore.unifiedpush.forbiddenEndpoint = true + } + // We tried to register without device + RegistrationStatus.NO_DEVICE, + // Should never be called: that means the linked device is deleted + RegistrationStatus.FORBIDDEN, + RegistrationStatus.NO_ENDPOINT, + RegistrationStatus.INTERNAL_ERROR -> { + SignalStore.unifiedpush.mollySocketInternalError = true + } + RegistrationStatus.NO_MOLLYSOCKET -> { + // Do nothing, the mollysocket url is already null + } + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/model/UnifiedPushStatus.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/model/UnifiedPushStatus.kt new file mode 100644 index 0000000000..d50330692c --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/model/UnifiedPushStatus.kt @@ -0,0 +1,18 @@ +package im.molly.unifiedpush.model + +enum class UnifiedPushStatus { + DISABLED, + AIR_GAPPED, + SERVER_NOT_FOUND_AT_URL, + MISSING_ENDPOINT, + FORBIDDEN_UUID, + FORBIDDEN_ENDPOINT, + NO_DISTRIBUTOR, + PENDING, + LINK_DEVICE_ERROR, + AIR_GAPPED_NOT_PINGED, + NOT_PINGED, + OK, + INTERNAL_ERROR, + UNKNOWN, +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt new file mode 100644 index 0000000000..faa0c1e611 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt @@ -0,0 +1,95 @@ +package im.molly.unifiedpush.receiver + +import android.content.Context +import androidx.core.os.bundleOf +import com.google.firebase.messaging.RemoteMessage +import im.molly.unifiedpush.jobs.UnifiedPushRefreshJob +import im.molly.unifiedpush.util.UnifiedPushNotificationBuilder +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.gcm.FcmFetchManager +import org.thoughtcrime.securesms.gcm.FcmReceiveService +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor +import org.unifiedpush.android.connector.MessagingReceiver + +class UnifiedPushReceiver : MessagingReceiver() { + + companion object { + private val TAG = Log.tag(UnifiedPushReceiver::class.java) + } + + private val executor = SerialMonoLifoExecutor(SignalExecutors.UNBOUNDED) + + override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + Log.d(TAG, "New endpoint !") + if (KeyCachingService.isLocked()) return + updateEndpoint(endpoint) + } + + override fun onRegistrationFailed(context: Context, instance: String) { + // called when the registration is not possible, eg. no network + if (KeyCachingService.isLocked()) return + UnifiedPushNotificationBuilder(context).setNotificationRegistrationFailed() + } + + override fun onUnregistered(context: Context, instance: String) { + // called when this application is unregistered from receiving push messages + // isPushAvailable becomes false => The websocket starts + Log.d(TAG, "Unregistered !") + if (KeyCachingService.isLocked()) return + updateEndpoint(endpoint = null) + } + + override fun onMessage(context: Context, message: ByteArray, instance: String) { + val msg = message.toString(Charsets.UTF_8) + + if (KeyCachingService.isLocked()) { + onMessageLocked(context, msg) + } else { + onMessageUnlocked(context, msg) + } + } + + private fun onMessageLocked(context: Context, message: String) { + when { + // We look directly in the message to avoid its deserialization + message.contains("\"urgent\":true") -> { + if (TextSecurePreferences.isPassphraseLockNotificationsEnabled(context)) { + Log.d(TAG, "New urgent message received while app is locked.") + FcmFetchManager.postMayHaveMessagesNotification(context) + } + } + } + } + + private fun onMessageUnlocked(context: Context, message: String) { + when { + message.contains("\"test\":true") -> { + Log.d(TAG, "Test message received.") + SignalStore.unifiedpush.pinged = true + UnifiedPushNotificationBuilder(context).setNotificationTest() + AppDependencies.jobManager.add(UnifiedPushRefreshJob()) + } + + else -> { + if (SignalStore.account.isRegistered && SignalStore.unifiedpush.enabled) { + Log.d(TAG, "New message") + executor.enqueue { + FcmReceiveService.handleReceivedNotification(context, RemoteMessage(bundleOf("google.delivered_priority" to "high"))) + } + } + } + } + } + + private fun updateEndpoint(endpoint: String?) { + if (SignalStore.unifiedpush.endpoint != endpoint) { + SignalStore.unifiedpush.endpoint = endpoint + AppDependencies.jobManager.add(UnifiedPushRefreshJob()) + } + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/util/MollySocketRequest.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/util/MollySocketRequest.kt new file mode 100644 index 0000000000..9a540f4b65 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/util/MollySocketRequest.kt @@ -0,0 +1,109 @@ +package im.molly.unifiedpush.util + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.JsonMappingException +import im.molly.unifiedpush.model.RegistrationStatus +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +import okhttp3.RequestBody +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.JsonUtils +import java.io.IOException +import java.net.MalformedURLException +import java.net.URL + +data class Response( + @JsonProperty("mollysocket") val mollySocket: ResponseMollySocket, +) + +data class ResponseMollySocket( + @JsonProperty("version") val version: String, + @JsonProperty("status") val status: RegistrationStatus?, +) + +data class ConnectionData( + @JsonProperty("uuid") val uuid: String, + @JsonProperty("device_id") val device_id: Int, + @JsonProperty("password") val password: String, + @JsonProperty("endpoint") val endpoint: String, +) + +object MollySocketRequest { + private val TAG = Log.tag(MollySocketRequest::class.java) + private val JsonMediaType = "application/json; charset=utf-8".toMediaTypeOrNull()!! + + fun discoverMollySocketServer(): Boolean { + try { + val url = SignalStore.unifiedpush.mollySocketUrl?.let { + URL(it) + } ?: return false + val request = Request.Builder().url(url).build() + val client = AppDependencies.okHttpClient.newBuilder().build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.d(TAG, "Unexpected code $response") + return false + } + val body = response.body ?: run { + Log.d(TAG, "Response body was not present") + return false + } + JsonUtils.fromJson(body.byteStream(), Response::class.java) + } + Log.d(TAG, "URL is OK") + } catch (e: Exception) { + Log.d(TAG, "Exception: $e") + return when (e) { + is MalformedURLException, + is JsonParseException, + is JsonMappingException, + is JsonProcessingException -> false + else -> throw IOException("Can not check server status") + } + } + return true + } + + fun registerToMollySocketServer(): RegistrationStatus { + try { + val data = SignalStore.unifiedpush.device?.let { + val endpoint = SignalStore.unifiedpush.endpoint ?: return RegistrationStatus.NO_ENDPOINT + ConnectionData( + uuid = it.uuid, + device_id = it.deviceId, + password = it.password, + endpoint = endpoint + ) + } ?: return RegistrationStatus.NO_DEVICE + + val url = SignalStore.unifiedpush.mollySocketUrl?.let { + URL(it) + } ?: return RegistrationStatus.NO_MOLLYSOCKET + + val postBody = RequestBody.create(JsonMediaType, JsonUtils.toJson(data)) + val request = Request.Builder().url(url).post(postBody).build() + val client = AppDependencies.okHttpClient.newBuilder().build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.d(TAG, "Unexpected code $response") + return RegistrationStatus.INTERNAL_ERROR + } + val body = response.body ?: run { + Log.d(TAG, "Response body was not present") + return RegistrationStatus.INTERNAL_ERROR + } + val resp = JsonUtils.fromJson(body.byteStream(), Response::class.java) + Log.d(TAG, "Status: ${resp.mollySocket.status}") + return resp.mollySocket.status ?: RegistrationStatus.INTERNAL_ERROR + } + } catch (e: Exception) { + Log.w(TAG, "Exception: $e", Throwable()) + return RegistrationStatus.INTERNAL_ERROR + } + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/util/UnifiedPushHelper.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/util/UnifiedPushHelper.kt new file mode 100644 index 0000000000..1e35068a0f --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/util/UnifiedPushHelper.kt @@ -0,0 +1,51 @@ +package im.molly.unifiedpush.util + +import android.content.Context +import im.molly.unifiedpush.device.MollySocketLinkedDevice +import im.molly.unifiedpush.model.UnifiedPushStatus +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.unifiedpush.android.connector.INSTANCE_DEFAULT +import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.ui.SelectDistributorDialogBuilder +import org.unifiedpush.android.connector.ui.UnifiedPushFunctions + +object UnifiedPushHelper { + private val TAG = Log.tag(UnifiedPushHelper::class.java) + + // return false if the initialization failed + fun initializeMollySocketLinkedDevice(context: Context): Boolean { + if (SignalStore.account.isRegistered) { + Log.d(TAG, "Initializing UnifiedPush") + MollySocketLinkedDevice(context).device ?: run { + Log.w(TAG, "Can't initialize the linked device for MollySocket") + return false + } + Log.d(TAG, "MollyDevice found") + } else { + return false + } + return true + } + + @JvmStatic + fun registerAppWithDialogIfNeeded(context: Context) { + checkDistributorPresence(context) + if (SignalStore.unifiedpush.status == UnifiedPushStatus.MISSING_ENDPOINT) { + object : SelectDistributorDialogBuilder( + context, + listOf(INSTANCE_DEFAULT), + object : UnifiedPushFunctions { + override fun getAckDistributor(): String? = UnifiedPush.getAckDistributor(context) + override fun getDistributors(): List = UnifiedPush.getDistributors(context) + override fun registerApp(instance: String) = UnifiedPush.registerApp(context, instance) + override fun saveDistributor(distributor: String) = UnifiedPush.saveDistributor(context, distributor) + }, + ){}.show() + } + } + + fun checkDistributorPresence(context: Context) { + UnifiedPush.getAckDistributor(context) ?: run { SignalStore.unifiedpush.endpoint = null } + } +} \ No newline at end of file diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/util/UnifiedPushNotificationBuilder.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/util/UnifiedPushNotificationBuilder.kt new file mode 100644 index 0000000000..c936012ce1 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/util/UnifiedPushNotificationBuilder.kt @@ -0,0 +1,56 @@ +package im.molly.unifiedpush.util + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.notifications.NotificationChannels + +class UnifiedPushNotificationBuilder(val context: Context) { + + private val NOTIFICATION_ID_UNIFIEDPUSH = 51215 + + private val builder: NotificationCompat.Builder = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_ALERTS) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(context.getString(R.string.NotificationDeliveryMethod__unifiedpush)) + .setContentIntent(null) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + private fun getNotification(content: String): Notification { + return builder.setContentText(content).setStyle( + NotificationCompat.BigTextStyle() + .bigText(content) + ).build() + } + + fun setNotificationDeviceLimitExceeded() { + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .notify(NOTIFICATION_ID_UNIFIEDPUSH, getNotification(context.getString(R.string.UnifiedPushNotificationBuilder__mollysocket_device_limit_exceed))) + } + + fun setNotificationMollySocketRegistrationChanged() { + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .notify(NOTIFICATION_ID_UNIFIEDPUSH, getNotification(context.getString(R.string.UnifiedPushNotificationBuilder__mollysocket_registration_changed))) + } + + fun setNotificationEndpointChangedAirGapped() { + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .notify(NOTIFICATION_ID_UNIFIEDPUSH, getNotification(context.getString(R.string.UnifiedPushNotificationBuilder__endpoint_changed_airgapped))) + } + + fun setNotificationEndpointChangedError() { + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .notify(NOTIFICATION_ID_UNIFIEDPUSH, getNotification(context.getString(R.string.UnifiedPushNotificationBuilder__endpoint_changed_error))) + } + + fun setNotificationRegistrationFailed() { + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .notify(NOTIFICATION_ID_UNIFIEDPUSH, getNotification(context.getString(R.string.UnifiedPushNotificationBuilder__registration_failed))) + } + + fun setNotificationTest() { + (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) + .notify(NOTIFICATION_ID_UNIFIEDPUSH, getNotification(context.getString(R.string.UnifiedPushNotificationBuilder__test))) + } +} diff --git a/app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt b/app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt new file mode 100644 index 0000000000..fac046ee76 --- /dev/null +++ b/app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.keyvalue + +import im.molly.unifiedpush.model.MollySocketDevice +import im.molly.unifiedpush.model.UnifiedPushStatus + +class UnifiedPushValues(store: KeyValueStore) : SignalStoreValues(store) { + + companion object { + private const val MOLLYSOCKET_UUID = "mollysocket.uuid" + private const val MOLLYSOCKET_DEVICE_ID = "mollysocket.deviceId" + private const val MOLLYSOCKET_PASSWORD = "mollysocket.password" + private const val MOLLYSOCKET_URL = "mollysocket.url" + private const val MOLLYSOCKET_OK = "mollysocket.ok" + private const val MOLLYSOCKET_FORBIDDEN_UUID = "mollysocket.forbidden_uuid" + private const val MOLLYSOCKET_FORBIDDEN_ENDPOINT = "mollysocket.forbidden_endpoint" + private const val MOLLYSOCKET_INTERNAL_ERROR = "mollysocket.internal_error" + private const val UNIFIEDPUSH_ENDPOINT = "up.endpoint" + private const val UNIFIEDPUSH_PENDING = "up.pending" + private const val UNIFIEDPUSH_AIR_GAPPED = "up.air_gapped" + private const val UNIFIEDPUSH_PINGED = "up.pinged" + } + + override fun onFirstEverAppLaunch() = Unit + + override fun getKeysToIncludeInBackup() = emptyList() + + @get:JvmName("isEnabled") + val enabled: Boolean = SignalStore.settings.preferredNotificationMethod == SettingsValues.NotificationDeliveryMethod.UNIFIEDPUSH + + var device: MollySocketDevice? + get() { + return MollySocketDevice( + uuid = getString(MOLLYSOCKET_UUID, null) ?: return null, + deviceId = getInteger(MOLLYSOCKET_DEVICE_ID, 0), + password = getString(MOLLYSOCKET_PASSWORD, null) ?: return null, + ) + } + set(device) { + store.beginWrite() + .putString(MOLLYSOCKET_UUID, device?.uuid) + .putInteger(MOLLYSOCKET_DEVICE_ID, device?.deviceId ?: 0) + .putString(MOLLYSOCKET_PASSWORD, device?.password) + .apply() + } + + var endpoint: String? by stringValue(UNIFIEDPUSH_ENDPOINT, null) + + var pending: Boolean by booleanValue(UNIFIEDPUSH_PENDING, false) + + @get:JvmName("isAirGapped") + var airGapped: Boolean by booleanValue(UNIFIEDPUSH_AIR_GAPPED, false) + + // This is set to true by default to avoid warning previous users, + // It is set to false when registering a new device in + // im.molly.unifiedpush.device.MollySocketLinkedDevice + var pinged: Boolean by booleanValue(UNIFIEDPUSH_PINGED, true) + + var mollySocketUrl: String? by stringValue(MOLLYSOCKET_URL, null) + + var mollySocketFound: Boolean by booleanValue(MOLLYSOCKET_OK, false) + + var forbiddenUuid: Boolean by booleanValue(MOLLYSOCKET_FORBIDDEN_UUID, true) + + var forbiddenEndpoint: Boolean by booleanValue(MOLLYSOCKET_FORBIDDEN_ENDPOINT, true) + + var mollySocketInternalError: Boolean by booleanValue(MOLLYSOCKET_INTERNAL_ERROR, true) + + val status: UnifiedPushStatus + get() = when { + SignalStore.settings.preferredNotificationMethod != SettingsValues.NotificationDeliveryMethod.UNIFIEDPUSH -> UnifiedPushStatus.DISABLED + SignalStore.unifiedpush.pending -> UnifiedPushStatus.PENDING + SignalStore.unifiedpush.device == null -> UnifiedPushStatus.LINK_DEVICE_ERROR + SignalStore.unifiedpush.endpoint == null -> UnifiedPushStatus.MISSING_ENDPOINT + SignalStore.unifiedpush.airGapped && + !SignalStore.unifiedpush.pinged -> UnifiedPushStatus.AIR_GAPPED_NOT_PINGED + SignalStore.unifiedpush.airGapped -> UnifiedPushStatus.AIR_GAPPED + SignalStore.unifiedpush.mollySocketUrl.isNullOrBlank() || + !SignalStore.unifiedpush.mollySocketFound -> UnifiedPushStatus.SERVER_NOT_FOUND_AT_URL + SignalStore.unifiedpush.mollySocketInternalError -> UnifiedPushStatus.INTERNAL_ERROR + SignalStore.unifiedpush.forbiddenUuid -> UnifiedPushStatus.FORBIDDEN_UUID + SignalStore.unifiedpush.forbiddenEndpoint -> UnifiedPushStatus.FORBIDDEN_ENDPOINT + !SignalStore.unifiedpush.pinged -> UnifiedPushStatus.NOT_PINGED + else -> UnifiedPushStatus.OK + } + + val isAvailableOrAirGapped: Boolean + get() = status in listOf(UnifiedPushStatus.OK, UnifiedPushStatus.AIR_GAPPED) +} diff --git a/dependencies.gradle.kts b/dependencies.gradle.kts index 4d919cd990..22a0b148d1 100644 --- a/dependencies.gradle.kts +++ b/dependencies.gradle.kts @@ -136,6 +136,10 @@ dependencyResolutionManagement { library("molly-argon2", "im.molly:argon2:13.1-1") library("molly-glide-webp-decoder", "im.molly:glide-webp-decoder:1.3.2-2") + // UnifiedPush + library("unifiedpush-connector", "org.unifiedpush.android:connector:2.5.0") + library("unifiedpush-connector-ui", "org.unifiedpush.android:connector-ui:1.1.0-rc2") + // Third Party library("greenrobot-eventbus", "org.greenrobot:eventbus:3.0.0") library("jackson-core", "com.fasterxml.jackson.core:jackson-databind:2.17.2") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index b3e5c3cb33..b1b3c82661 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -8424,6 +8424,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -8504,6 +8512,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -9581,6 +9594,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + +