diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4ff4aed961..5717955328 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 {
@@ -532,6 +536,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 770a63c5fd..2eb97b06b6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1311,6 +1311,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 0dc37d962c..80787e2512 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
@@ -46,6 +46,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
@@ -356,6 +357,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_service),
@@ -367,6 +369,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()
+ }
+ )
}
}
@@ -392,9 +402,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 8dd5807299..b29e239091 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
@@ -162,6 +162,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 53bb3dc77f..d4d2e73570 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
@@ -97,6 +97,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) {
@@ -142,6 +144,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 7ebf35fcf2..154cb16f55 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 89b85846b9..e29c87f202 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java
@@ -564,7 +564,7 @@ public enum Theme {
}
public enum NotificationDeliveryMethod {
- FCM, WEBSOCKET;
+ FCM, WEBSOCKET, UNIFIEDPUSH;
public @NonNull String serialize() {
return name();
@@ -578,6 +578,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 154fce99c7..81fe9fe6db 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 c704632817..0c5cee7816 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
@@ -450,6 +450,14 @@
app:popEnterAnim="@anim/fragment_close_enter"
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
@@ -128,6 +129,45 @@
Push notifications
Select your preferred service for push notifications. If unavailable, the app will automatically use WebSocket to ensure notifications are delivered.
Delivery service
+ 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
FCM (Google Play Services)
WebSocket
WebSocket Service
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 3826aa01bc..0334e79a38 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 8eec38ac9d..3d0ec99faa 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -8429,6 +8429,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+
@@ -8509,6 +8517,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
@@ -9586,6 +9599,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+