From 9a02d726affa02b8fb5abda44917ffb64f6a53a6 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Sat, 16 Nov 2024 21:49:33 +0100 Subject: [PATCH 01/13] Inject SignalWebSocket into IncomingMessageObserver --- .../securesms/dependencies/AppDependencies.kt | 2 +- .../dependencies/ApplicationDependencyProvider.java | 4 ++-- .../dependencies/NetworkDependenciesModule.kt | 2 +- .../securesms/messages/IncomingMessageObserver.kt | 10 +++++----- .../dependencies/MockApplicationDependencyProvider.kt | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index e74af315dc..0a22a002d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -331,7 +331,7 @@ object AppDependencies { fun provideMegaphoneRepository(): MegaphoneRepository fun provideEarlyMessageCache(): EarlyMessageCache fun provideMessageNotifier(): MessageNotifier - fun provideIncomingMessageObserver(): IncomingMessageObserver + fun provideIncomingMessageObserver(signalWebSocket: SignalWebSocket): IncomingMessageObserver fun provideTrimThreadsByDateManager(): TrimThreadsByDateManager fun provideViewOnceMessageManager(): ViewOnceMessageManager fun provideExpiringStoriesManager(): ExpiringStoriesManager diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 5988b0e8f9..9e9e7ff308 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -210,8 +210,8 @@ public ApplicationDependencyProvider(@NonNull Application context) { } @Override - public @NonNull IncomingMessageObserver provideIncomingMessageObserver() { - return new IncomingMessageObserver(context); + public @NonNull IncomingMessageObserver provideIncomingMessageObserver(@NonNull SignalWebSocket signalWebSocket) { + return new IncomingMessageObserver(context, signalWebSocket); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index fa18ae7872..6b27bef876 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -73,7 +73,7 @@ class NetworkDependenciesModule( val signalServiceMessageSender: SignalServiceMessageSender by _signalServiceMessageSender val incomingMessageObserver: IncomingMessageObserver by lazy { - provider.provideIncomingMessageObserver() + provider.provideIncomingMessageObserver(signalWebSocket) } val pushServiceSocket: PushServiceSocket by lazy { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt index 616ef9effe..ebba445c31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.util.AlarmSleepTimer import org.thoughtcrime.securesms.util.AppForegroundObserver import org.thoughtcrime.securesms.util.SignalLocalMetrics import org.thoughtcrime.securesms.util.asChain +import org.whispersystems.signalservice.api.SignalWebSocket import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.util.SleepTimer import org.whispersystems.signalservice.api.util.UptimeSleepTimer @@ -53,7 +54,7 @@ import kotlin.time.Duration.Companion.seconds * * This class is responsible for opening/closing the websocket based on the app's state and observing new inbound messages received on the websocket. */ -class IncomingMessageObserver(private val context: Application) { +class IncomingMessageObserver(private val context: Application, private val signalWebSocket: SignalWebSocket) { companion object { private val TAG = Log.tag(IncomingMessageObserver::class.java) @@ -231,7 +232,7 @@ class IncomingMessageObserver(private val context: Application) { } private fun disconnect() { - AppDependencies.signalWebSocket.disconnect() + signalWebSocket.disconnect() } @JvmOverloads @@ -371,8 +372,7 @@ class IncomingMessageObserver(private val context: Application) { waitForConnectionNecessary() Log.i(TAG, "Making websocket connection....") - val signalWebSocket = AppDependencies.signalWebSocket - val webSocketDisposable = AppDependencies.webSocketObserver.subscribe { state: WebSocketConnectionState -> + val webSocketDisposable = signalWebSocket.webSocketState.subscribe { state: WebSocketConnectionState -> Log.d(TAG, "WebSocket State: $state") // Any change to a non-connected state means that we are not drained @@ -383,7 +383,7 @@ class IncomingMessageObserver(private val context: Application) { signalWebSocket.connect(shouldKeepAliveUnidentified()) try { - while (isConnectionNecessary()) { + while (!terminated && isConnectionNecessary()) { try { Log.d(TAG, "Reading message...") diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 4bcde055e0..5b18764957 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -98,7 +98,7 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk() } - override fun provideIncomingMessageObserver(): IncomingMessageObserver { + override fun provideIncomingMessageObserver(signalWebSocket: SignalWebSocket): IncomingMessageObserver { return mockk() } From 4d00414d22f726821f76c3d0e777f7c9ec2c9e0c Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 25 Mar 2023 19:20:59 +0100 Subject: [PATCH 02/13] UnifiedPush Co-authored-by: Oscar Mira --- app/build.gradle.kts | 10 + app/proguard/proguard.cfg | 1 + app/src/main/AndroidManifest.xml | 12 + .../unifiedpush/MollySocketRepository.kt | 168 ++++++++++++++ .../securesms/ApplicationContext.java | 40 +++- .../thoughtcrime/securesms/MainActivity.java | 5 + .../app/internal/InternalSettingsFragment.kt | 16 ++ .../NotificationsSettingsFragment.kt | 65 +++++- .../NotificationsSettingsState.kt | 3 +- .../NotificationsSettingsViewModel.kt | 8 +- .../securesms/gcm/FcmReceiveService.java | 3 +- .../securesms/jobs/JobManagerFactories.java | 3 + .../securesms/keyvalue/AccountValues.kt | 2 +- .../securesms/keyvalue/InternalValues.java | 4 - .../securesms/keyvalue/SettingsValues.java | 3 +- .../securesms/keyvalue/SignalStore.kt | 6 + .../logsubmit/LogSectionSystemInfo.java | 1 + .../notifications/VitalsViewModel.kt | 9 +- .../data/RegistrationRepository.kt | 1 - .../app_settings_with_change_number.xml | 13 ++ app/src/main/res/values/strings2.xml | 36 +++ .../unifiedpush/UnifiedPushDistributor.kt | 47 ++++ .../UnifiedPushNotificationBuilder.kt | 70 ++++++ .../UnifiedPushSettingsFragment.kt | 218 ++++++++++++++++++ .../notifications/UnifiedPushSettingsState.kt | 21 ++ .../UnifiedPushSettingsViewModel.kt | 152 ++++++++++++ .../unifiedpush/jobs/UnifiedPushRefreshJob.kt | 186 +++++++++++++++ .../unifiedpush/model/ConnectionRequest.kt | 30 +++ .../unifiedpush/model/MollySocketDevice.kt | 35 +++ .../receiver/UnifiedPushReceiver.kt | 113 +++++++++ .../securesms/keyvalue/UnifiedPushValues.kt | 59 +++++ dependencies.gradle.kts | 4 + gradle/verification-metadata.xml | 29 +++ 33 files changed, 1349 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/im/molly/unifiedpush/MollySocketRepository.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt 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/jobs/UnifiedPushRefreshJob.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/model/ConnectionRequest.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.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 7357ac8303..cf466d3608 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 b79bc97439..9f1ed31a36 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1302,6 +1302,18 @@ + + + + + + + + + + if (!response.isSuccessful) { + Log.d(TAG, "Unexpected code: $response") + return false + } + val body = response.body ?: run { + Log.d(TAG, "No response body") + 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 + } + + @Throws(IOException::class) + fun registerDeviceOnServer( + url: HttpUrl, + device: MollySocketDevice, + endpoint: String, + ping: Boolean = false, + ): ConnectionResult? { + val requestData = ConnectionRequest( + uuid = SignalStore.account.requireAci().toString(), + deviceId = device.deviceId, + password = device.password, + endpoint = endpoint, + ping = ping, + ) + + val postBody = JsonUtils.toJson(requestData).toRequestBody(MEDIA_TYPE_JSON) + 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 null + } + val body = response.body ?: run { + Log.d(TAG, "No response body") + return null + } + + val resp = JsonUtils.fromJson(body.byteStream(), Response::class.java) + + val status = resp.mollySocket.status + Log.d(TAG, "Status: $status") + + return status + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 246f286628..4564aac8f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -81,6 +81,7 @@ import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob; import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob; import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration; +import org.thoughtcrime.securesms.keyvalue.SettingsValues.NotificationDeliveryMethod; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger; import org.thoughtcrime.securesms.logging.PersistentLogger; @@ -127,6 +128,8 @@ import java.util.Map; import java.util.concurrent.TimeUnit; +import im.molly.unifiedpush.UnifiedPushDistributor; +import im.molly.unifiedpush.jobs.UnifiedPushRefreshJob; import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException; import io.reactivex.rxjava3.exceptions.UndeliverableException; import io.reactivex.rxjava3.plugins.RxJavaPlugins; @@ -504,18 +507,35 @@ public void updatePushNotificationServices() { return; } + NotificationDeliveryMethod method = SignalStore.settings().getPreferredNotificationMethod(); + boolean fcmEnabled = SignalStore.account().isFcmEnabled(); - boolean forceWebSocket = SignalStore.internal().isWebsocketModeForced(); - boolean forceFcm = !forceWebSocket && SignalStore.internal().isFcmModeForced(); + boolean unifiedPushEnabled = SignalStore.unifiedpush().isEnabled(); - if (forceWebSocket || !BuildConfig.USE_PLAY_SERVICES) { + if (method != NotificationDeliveryMethod.FCM || !BuildConfig.USE_PLAY_SERVICES) { if (fcmEnabled) { Log.i(TAG, "Play Services not allowed. Disabling FCM."); updateFcmStatus(false); } else { - Log.d(TAG, "FCM is disabled."); + Log.d(TAG, "FCM is already disabled."); + } + if (method == NotificationDeliveryMethod.UNIFIEDPUSH) { + if (SignalStore.account().isLinkedDevice()) { + Log.i(TAG, "UnifiedPush not supported in linked devices."); + updateUnifiedPushStatus(false); + } else if (!unifiedPushEnabled) { + Log.i(TAG, "Switching to UnifiedPush."); + updateUnifiedPushStatus(true); + } else { + AppDependencies.getJobManager().add(new UnifiedPushRefreshJob()); + } + } else { + if (unifiedPushEnabled) { + Log.i(TAG, "Switching to WebSocket."); + updateUnifiedPushStatus(false); + } } - } else if (forceFcm && !fcmEnabled && BuildConfig.USE_PLAY_SERVICES) { + } else if (!fcmEnabled) { Log.i(TAG, "FCM preferred. Updating to use FCM."); updateFcmStatus(true); } else { @@ -545,6 +565,16 @@ private void updateFcmStatus(boolean fcmEnabled) { .enqueue(); } + private void updateUnifiedPushStatus(boolean enabled) { + SignalStore.unifiedpush().setEnabled(enabled); + if (enabled) { + UnifiedPushDistributor.registerApp(); + } else { + UnifiedPushDistributor.unregisterApp(); + } + AppDependencies.getJobManager().add(new UnifiedPushRefreshJob()); + } + private void initializeExpiringMessageManager() { AppDependencies.getExpiringMessageManager().checkSchedule(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java index 1d9dc4d0e9..9c127c9f9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java @@ -43,6 +43,8 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.WindowUtil; +import im.molly.unifiedpush.UnifiedPushDistributor; + public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner { private static final String KEY_STARTING_TAB = "STARTING_TAB"; @@ -131,6 +133,9 @@ private void presentVitalsState(VitalsViewModel.State state) { switch (state) { case NONE: break; + case PROMPT_UNIFIEDPUSH_SELECT_DISTRIBUTOR_DIALOG: + UnifiedPushDistributor.showSelectDistributorDialog(this); + break; case PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG: DeviceSpecificNotificationBottomSheet.show(getSupportFragmentManager()); break; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 676a403ed8..775447428f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -219,6 +219,22 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } ) + clickPref( + title = DSLSettingsText.from("Delete UnifiedPush ping"), + summary = DSLSettingsText.from("Make as Molly never received the ping from MollySocket. Will cause UnifiedPush to stop and Websocket to restart."), + onClick = { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Delete UnifiedPush ping?") + .setMessage("Are you sure?") + .setPositiveButton(android.R.string.ok) { _, _ -> + SignalStore.unifiedpush.lastReceivedTime = 0 + 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 72e3959a22..a5632a4e45 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 @@ -47,6 +47,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 @@ -76,11 +77,6 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__ private val ledBlinkValues by lazy { resources.getStringArray(R.array.pref_led_blink_pattern_values) } private val ledBlinkLabels by lazy { resources.getStringArray(R.array.pref_led_blink_pattern_entries) } - private val notificationMethodValues = NotificationDeliveryMethod.entries.filterNot { method -> - method == NotificationDeliveryMethod.FCM && !BuildConfig.USE_PLAY_SERVICES - } - private val notificationMethodLabels by lazy { notificationMethodValues.map { resources.getString(it.stringId) }.toTypedArray() } - private lateinit var viewModel: NotificationsSettingsViewModel private val args: NotificationsSettingsFragmentArgs by navArgs() @@ -362,22 +358,75 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__ summary = DSLSettingsText.from(R.string.NotificationsSettingsFragment__select_your_preferred_service_for_push_notifications) ) + val notificationMethods = NotificationDeliveryMethod.entries.filter { method -> + when (method) { + NotificationDeliveryMethod.FCM -> BuildConfig.USE_PLAY_SERVICES + NotificationDeliveryMethod.WEBSOCKET -> true + NotificationDeliveryMethod.UNIFIEDPUSH -> !state.isLinkedDevice + } + } + 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), - listItems = notificationMethodLabels, - selected = notificationMethodValues.indexOf(state.preferredNotificationMethod), + listItems = notificationMethods.map { resources.getString(it.stringId) }.toTypedArray(), + selected = notificationMethods.indexOf(state.preferredNotificationMethod), iconEnd = if (showAlertIcon) DSLSettingsIcon.from(R.drawable.ic_alert, R.color.signal_alert_primary) else null, onSelected = { - viewModel.setPreferredNotificationMethod(notificationMethodValues[it]) + onNotificationMethodChanged(notificationMethods[it], state.preferredNotificationMethod) } ) + + if (!state.isLinkedDevice) { + clickPref( + title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__configure_unifiedpush), + isEnabled = state.preferredNotificationMethod == NotificationDeliveryMethod.UNIFIEDPUSH, + onClick = { + navigateToUnifiedPushSettings() + } + ) + } } } + private fun onNotificationMethodChanged( + method: NotificationDeliveryMethod, + previousMethod: NotificationDeliveryMethod + ) { + when (method) { + NotificationDeliveryMethod.FCM -> viewModel.setPreferredNotificationMethod(method) + 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.initializeUnifiedPushDistributor() + 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 showPlayServicesErrorDialog(errorCode: Int) { val causeId = when (errorCode) { ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED -> { 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 d6dce0440d..71b73cdcc2 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 @@ -12,7 +12,8 @@ data class NotificationsSettingsState( val isLinkedDevice: Boolean, val preferredNotificationMethod: NotificationDeliveryMethod, val playServicesErrorCode: Int?, - 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 8cb9b7f758..4d3a45ec8d 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 @@ -6,6 +6,7 @@ import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import im.molly.unifiedpush.UnifiedPushDistributor import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -119,6 +120,10 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer refresh() } + fun initializeUnifiedPushDistributor() { + UnifiedPushDistributor.selectFirstDistributor() + } + fun setPlayServicesErrorCode(errorCode: Int?) { store.update { it.copy(playServicesErrorCode = errorCode) } } @@ -161,7 +166,8 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer isLinkedDevice = SignalStore.account.isLinkedDevice, preferredNotificationMethod = SignalStore.settings.preferredNotificationMethod, playServicesErrorCode = currentState?.playServicesErrorCode, - canReceiveFcm = SignalStore.account.canReceiveFcm + 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/InternalValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java index 540f744385..10b5807183 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java @@ -172,10 +172,6 @@ public boolean isWebsocketModeForced() { return SignalStore.settings().getPreferredNotificationMethod() == SettingsValues.NotificationDeliveryMethod.WEBSOCKET; } - public boolean isFcmModeForced() { - return SignalStore.settings().getPreferredNotificationMethod() == SettingsValues.NotificationDeliveryMethod.FCM; - } - public void setHevcEncoding(boolean enabled) { putBoolean(ENCODE_HEVC, enabled); } 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/notifications/VitalsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/VitalsViewModel.kt index 8ce849ff64..b2441c1d29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/VitalsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/VitalsViewModel.kt @@ -7,11 +7,13 @@ package org.thoughtcrime.securesms.notifications import android.app.Application import androidx.lifecycle.AndroidViewModel +import im.molly.unifiedpush.UnifiedPushDistributor import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.notifications.DeviceSpecificNotificationConfig.ShowCondition import java.util.concurrent.TimeUnit @@ -28,7 +30,7 @@ class VitalsViewModel(private val context: Application) : AndroidViewModel(conte vitalsState = checkSubject .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) - .throttleFirst(15, TimeUnit.MINUTES) + .throttleFirst(5, TimeUnit.MINUTES) .switchMapSingle { checkHeuristics() } @@ -42,6 +44,10 @@ class VitalsViewModel(private val context: Application) : AndroidViewModel(conte private fun checkHeuristics(): Single { return Single.fromCallable { + if (SignalStore.unifiedpush.enabled && !UnifiedPushDistributor.checkIfActive()) { + return@fromCallable State.PROMPT_UNIFIEDPUSH_SELECT_DISTRIBUTOR_DIALOG + } + val deviceSpecificCondition = SlowNotificationHeuristics.getDeviceSpecificShowCondition() if (deviceSpecificCondition == ShowCondition.ALWAYS && SlowNotificationHeuristics.shouldShowDeviceSpecificDialog()) { @@ -66,6 +72,7 @@ class VitalsViewModel(private val context: Application) : AndroidViewModel(conte enum class State { NONE, + PROMPT_UNIFIEDPUSH_SELECT_DISTRIBUTOR_DIALOG, PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG, PROMPT_GENERAL_BATTERY_SAVER_DIALOG, } 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 805e1b149b..cff79bb946 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 d2e5f24ec1..f730e74407 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 @@ -452,6 +452,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 @@ -130,6 +131,41 @@ 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 + Distributor app + Status + Tap to copy to clipboard + Air gapped + Server parameters + Account ID + Server URL + Please provide the MollySocket server URL + Test configuration + Tap to request a test notification from MollySocket + A test notification should appear in a few moments… + Invalid server URL + Missing MollySocket URL + Pending + Waiting for confirmation from the MollySocket server + Invalid response from server + The account ID is refused by the server + Unable to create the linked device + Waiting for UnifiedPush distributor response + The endpoint is forbidden by the server + No UnifiedPush distributor installed + No distributor app selected + Enable if your MollySocket server can\'t be reached from the internet. You\'ll need to manually add your account to the server. + "MollySocket server not found. Please check the URL and try again." + You\'ve reached the limit of %d linked devices. To link your MollySocket server, please remove a device first. + Your UnifiedPush endpoint has changed. You must update your connection on MollySocket. + Your registration on MollySocket is no longer valid. Please remove the linked device and try registering again. + Registration with your UnifiedPush distributor failed. This could be due to a network issue or a missing requirement from your distributor. + This is a test notification from the MollySocket server. + UnifiedPush FCM (Google Play Services) WebSocket WebSocket Service diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt new file mode 100644 index 0000000000..2449f55714 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt @@ -0,0 +1,47 @@ +package im.molly.unifiedpush + +import android.content.Context +import org.thoughtcrime.securesms.dependencies.AppDependencies +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 UnifiedPushDistributor { + + @JvmStatic + fun registerApp() { + UnifiedPush.registerApp(AppDependencies.application) + } + + @JvmStatic + fun unregisterApp() { + UnifiedPush.unregisterApp(AppDependencies.application) + } + + fun selectFirstDistributor() { + val context = AppDependencies.application + UnifiedPush.getSavedDistributor(context) + ?: UnifiedPush.getDistributors(context).firstOrNull()?.also { + UnifiedPush.saveDistributor(context, it) + } + } + + @JvmStatic + fun showSelectDistributorDialog(context: Context) { + 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 checkIfActive(): Boolean { + return UnifiedPush.getAckDistributor(AppDependencies.application) != null + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt new file mode 100644 index 0000000000..b8bc8dbc41 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt @@ -0,0 +1,70 @@ +package im.molly.unifiedpush + +import android.Manifest +import android.app.Notification +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.notifications.NotificationChannels + +class UnifiedPushNotificationBuilder(val context: Context) { + + companion object { + private const val NOTIFICATION_ID = 51215 + private const val NOTIFICATION_TEST_ID = 51216 + } + + private val notificationManager = NotificationManagerCompat.from(context) + + 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() + } + + private fun notify(notificationId: Int, content: String) { + val hasPermission = if (Build.VERSION.SDK_INT >= 33) { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else true + + if (hasPermission) { + notificationManager.notify(notificationId, getNotification(content)) + } + } + + fun clearAlerts() { + notificationManager.cancel(NOTIFICATION_ID) + } + + fun setNotificationDeviceLimitExceeded(deviceLimit: Int) { + notify(NOTIFICATION_ID, context.getString(R.string.UnifiedPushNotificationBuilder__mollysocket_device_limit_hit, deviceLimit - 1)) + } + + fun setNotificationMollySocketRegistrationChanged() { + notify(NOTIFICATION_ID, context.getString(R.string.UnifiedPushNotificationBuilder__mollysocket_registration_changed)) + } + + fun setNotificationEndpointChangedAirGapped() { + notify(NOTIFICATION_ID, context.getString(R.string.UnifiedPushNotificationBuilder__endpoint_changed_air_gapped)) + } + + fun setNotificationRegistrationFailed() { + notify(NOTIFICATION_ID, context.getString(R.string.UnifiedPushNotificationBuilder__registration_failed)) + } + + fun setNotificationTest() { + notify(NOTIFICATION_TEST_ID, context.getString(R.string.UnifiedPushNotificationBuilder__this_is_a_test_notification)) + } +} 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..f583b5408a --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt @@ -0,0 +1,218 @@ +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 android.widget.Toast +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import im.molly.unifiedpush.model.RegistrationStatus +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) + + viewModel.checkMollySocketFromStoredUrl() + } + + @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)), + ) + + if (state.distributors.isEmpty()) { + textPref( + title = DSLSettingsText.from(R.string.UnifiedPushSettingsFragment__distributor_app), + summary = DSLSettingsText.from(R.string.UnifiedPushSettingsFragment__none_available) + ) + } else { + radioListPref( + title = DSLSettingsText.from(R.string.UnifiedPushSettingsFragment__distributor_app), + 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) { + val parameters = getServerParameters(state) ?: "" + + clickPref( + title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__server_parameters)), + summary = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__tap_to_copy_to_clipboard)), + iconEnd = DSLSettingsIcon.from(R.drawable.symbol_copy_android_24), + isEnabled = parameters.isNotEmpty(), + onClick = { + writeTextToClipboard(requireContext(), "Server parameters", parameters) + }, + ) + } else { + val aciOrUnknown = state.aci ?: getString(R.string.Recipient_unknown) + + clickPref( + title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__account_id)), + summary = DSLSettingsText.from(aciOrUnknown), + iconEnd = DSLSettingsIcon.from(R.drawable.symbol_copy_android_24), + onClick = { + writeTextToClipboard(requireContext(), "Account ID", aciOrUnknown) + }, + ) + + 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) }, + ) + + clickPref( + title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__test_configuration)), + summary = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__tap_to_request_a_test_notification_from_mollysocket)), + onClick = { + viewModel.pingMollySocket() + Toast.makeText(context, getString(R.string.UnifiedPushSettingsFragment__a_test_notification_should_appear_in_a_few_moments), Toast.LENGTH_SHORT).show() + }, + ) + } + } + } + + private fun getServerParameters(state: UnifiedPushSettingsState): String? { + val aci = state.aci ?: return null + val device = state.device ?: return null + val endpoint = state.endpoint ?: return null + return "connection add $aci ${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 -> + val isValid = viewModel.setMollySocketUrl(input.text.toString()) + if (!isValid && input.text.isNotEmpty()) { + Toast.makeText(requireContext(), R.string.UnifiedPushSettingsFragment__invalid_server_url, Toast.LENGTH_LONG).show() + } + } + 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 + } + + @StringRes + private fun getStatusSummary(state: UnifiedPushSettingsState): Int { + return when { + state.distributors.isEmpty() -> R.string.UnifiedPushSettingsFragment__status_summary_no_distributor + state.selected == -1 -> R.string.UnifiedPushSettingsFragment__status_summary_distributor_not_selected + state.endpoint == null -> R.string.UnifiedPushSettingsFragment__status_summary_missing_endpoint + state.mollySocketUrl == null && !state.airGapped -> R.string.UnifiedPushSettingsFragment__status_summary_mollysocket_url_missing + state.device == null -> R.string.UnifiedPushSettingsFragment__status_summary_linked_device_error + else -> getRegistrationStatusSummary(state) + } + } + + @StringRes + private fun getRegistrationStatusSummary(state: UnifiedPushSettingsState): Int { + return if (state.airGapped) { + when (state.registrationStatus) { + RegistrationStatus.REGISTERED -> android.R.string.ok + else -> R.string.UnifiedPushSettingsFragment__status_summary_air_gapped_pending + } + } else { + when (state.registrationStatus) { + RegistrationStatus.UNKNOWN, + RegistrationStatus.PENDING -> R.string.UnifiedPushSettingsFragment__status_summary_pending + + RegistrationStatus.BAD_RESPONSE, + RegistrationStatus.SERVER_ERROR -> R.string.UnifiedPushSettingsFragment__status_summary_bad_response + + RegistrationStatus.REGISTERED -> android.R.string.ok + RegistrationStatus.FORBIDDEN_UUID -> R.string.UnifiedPushSettingsFragment__status_summary_forbidden_uuid + RegistrationStatus.FORBIDDEN_ENDPOINT -> R.string.UnifiedPushSettingsFragment__status_summary_forbidden_endpoint + } + } + } + + private fun getMollySocketUrlIcon(state: UnifiedPushSettingsState): DSLSettingsIcon? { + return when (state.serverUnreachable) { + true -> DSLSettingsIcon.from(R.drawable.ic_alert) + false -> DSLSettingsIcon.from(R.drawable.ic_check_20) + else -> null + } + } +} 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..e14e5d1883 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt @@ -0,0 +1,21 @@ +package im.molly.unifiedpush.components.settings.app.notifications + +import im.molly.unifiedpush.model.MollySocketDevice +import im.molly.unifiedpush.model.RegistrationStatus + +data class Distributor( + val applicationId: String, + val name: String, +) + +data class UnifiedPushSettingsState( + val airGapped: Boolean, + val device: MollySocketDevice?, + val aci: String?, + val registrationStatus: RegistrationStatus, + val distributors: List, + val selected: Int, + val endpoint: String?, + val mollySocketUrl: String?, + val serverUnreachable: Boolean?, +) 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..77bfc96d0c --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt @@ -0,0 +1,152 @@ +package im.molly.unifiedpush.components.settings.app.notifications + +import android.app.Application +import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import im.molly.unifiedpush.MollySocketRepository +import im.molly.unifiedpush.jobs.UnifiedPushRefreshJob +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.signal.core.util.ThreadUtil +import org.signal.core.util.concurrent.SignalExecutors +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 store = Store(getState()) + private val executor = SerialMonoLifoExecutor(SignalExecutors.UNBOUNDED) + + private var serverUnreachable: Boolean? = null + + val state: LiveData = store.stateLiveData + + fun refresh() { + store.update { getState() } + } + + private fun refreshAndUpdateRegistration(pingOnRegister: Boolean = false) { + refresh() + AppDependencies.jobManager.add(UnifiedPushRefreshJob(pingOnRegister)) + } + + private fun getState(): UnifiedPushSettingsState { + val distributorIds = UnifiedPush.getDistributors(application) + val saved = UnifiedPush.getSavedDistributor(application) + + val selected = distributorIds.indexOfFirst { it == saved } + + val distributors = distributorIds.map { appId -> + val name = try { + val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + application.packageManager.getApplicationInfo( + appId, PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()) + ) + } else { + application.packageManager.getApplicationInfo(appId, 0) + } + application.packageManager.getApplicationLabel(ai) + } catch (e: PackageManager.NameNotFoundException) { + appId + } as String + + Distributor( + applicationId = appId, + name = name, + ) + } + + val mollySocketUrl = SignalStore.unifiedpush.mollySocketUrl + + return UnifiedPushSettingsState( + airGapped = SignalStore.unifiedpush.airGapped, + device = SignalStore.unifiedpush.device, + aci = SignalStore.account.aci?.toString(), + registrationStatus = SignalStore.unifiedpush.registrationStatus, + distributors = distributors, + selected = selected, + endpoint = SignalStore.unifiedpush.endpoint, + mollySocketUrl = mollySocketUrl, + serverUnreachable = serverUnreachable, + ) + } + + fun setUnifiedPushAirGapped(airGapped: Boolean) { + SignalStore.unifiedpush.lastReceivedTime = 0 + SignalStore.unifiedpush.airGapped = airGapped + refreshAndUpdateRegistration() + } + + fun setUnifiedPushDistributor(distributor: String) { + SignalStore.unifiedpush.endpoint = null + UnifiedPush.saveDistributor(application, distributor) + refreshAndUpdateRegistration() + } + + fun setMollySocketUrl(url: String): Boolean { + SignalStore.unifiedpush.lastReceivedTime = 0 + + val normalizedUrl = if (url.lastOrNull() != '/') "$url/" else url + val httpUrl = normalizedUrl.toHttpUrlOrNull() + + return if (httpUrl != null) { + SignalStore.unifiedpush.mollySocketUrl = normalizedUrl + checkMollySocketServer(normalizedUrl) + true + } else { + SignalStore.unifiedpush.mollySocketUrl = null + serverUnreachable = null + false + }.also { + refresh() + } + } + + fun checkMollySocketFromStoredUrl() { + checkMollySocketServer(SignalStore.unifiedpush.mollySocketUrl ?: return) + } + + private fun checkMollySocketServer(url: String) { + executor.enqueue { + val found = runCatching { + MollySocketRepository.discoverMollySocketServer(url.toHttpUrl()) + }.getOrElse { false } + + // Update server reachability status + serverUnreachable = !found + refreshAndUpdateRegistration() + + if (!found) { + showServerNotFoundToast() + } + } + } + + private fun showServerNotFoundToast() { + ThreadUtil.runOnMain { + Toast.makeText( + application, + R.string.UnifiedPushSettingsViewModel__mollysocket_server_not_found, + Toast.LENGTH_LONG + ).show() + } + } + + fun pingMollySocket() { + refreshAndUpdateRegistration(pingOnRegister = true) + } + + 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/jobs/UnifiedPushRefreshJob.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt new file mode 100644 index 0000000000..a56e1bdea5 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt @@ -0,0 +1,186 @@ +package im.molly.unifiedpush.jobs + +import im.molly.unifiedpush.UnifiedPushDistributor +import im.molly.unifiedpush.UnifiedPushNotificationBuilder +import org.greenrobot.eventbus.EventBus +import org.signal.core.util.logging.Log +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.SignalStore +import im.molly.unifiedpush.MollySocketRepository +import im.molly.unifiedpush.MollySocketRepository.isLinked +import im.molly.unifiedpush.model.RegistrationStatus +import im.molly.unifiedpush.model.toRegistrationStatus +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.thoughtcrime.securesms.jobmanager.JsonJobData +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException +import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Handles UnifiedPush registration and ensures the MollySocket status is up-to-date. + * Unregisters if the account is not registered or UnifiedPush is disabled. + */ +class UnifiedPushRefreshJob private constructor( + private val testPing: Boolean, + parameters: Parameters, +) : BaseJob(parameters) { + + constructor() : this(testPing = false) + + constructor(testPing: Boolean) : this( + testPing = testPing, + parameters = Parameters.Builder() + .setQueue(FcmRefreshJob.QUEUE_KEY) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(3) + .setLifespan(TimeUnit.HOURS.toMillis(6)) + .setMaxInstancesForFactory(2) + .build() + ) + + @Throws(Exception::class) + public override fun onRun() { + val hasAccount = SignalStore.account.isRegistered + val enabled = SignalStore.unifiedpush.enabled + val currentStatus = SignalStore.unifiedpush.registrationStatus + + Log.d(TAG, "Current registration status: $currentStatus") + + if (!hasAccount || !enabled) { + Log.d(TAG, "UnifiedPush is disabled.") + return + } + + try { + val newStatus = checkRegistrationStatus() + + if (currentStatus == newStatus) { + Log.d(TAG, "Registration status unchanged.") + } else { + Log.d(TAG, "Updated registration status: $newStatus") + SignalStore.unifiedpush.registrationStatus = newStatus + } + + if (newStatus == RegistrationStatus.REGISTERED) { + UnifiedPushNotificationBuilder(context).clearAlerts() + } else if (newStatus.notifyUser) { + UnifiedPushNotificationBuilder(context).setNotificationMollySocketRegistrationChanged() + } + } catch (t: Throwable) { + Log.e(TAG, "Error checking registration status", t) + + if (currentStatus != RegistrationStatus.REGISTERED) { + SignalStore.unifiedpush.registrationStatus = RegistrationStatus.UNKNOWN + } + + // Re-throw the exception as IOException for retry + when (t) { + is IOException -> throw t + else -> throw IOException(t) + } + } finally { + AppDependencies.resetNetwork(restartMessageObserver = true) + EventBus.getDefault().post(PushServiceEvent) + } + } + + @Throws(IOException::class) + private fun checkRegistrationStatus(): RegistrationStatus { + val endpoint = SignalStore.unifiedpush.endpoint + val airGapped = SignalStore.unifiedpush.airGapped + val mollySocketUrl = SignalStore.unifiedpush.mollySocketUrl?.toHttpUrlOrNull() + val lastReceivedTime = SignalStore.unifiedpush.lastReceivedTime + + Log.d(TAG, "Last notification received at: $lastReceivedTime") + + SignalStore.unifiedpush.device?.let { device -> + if (!device.isLinked()) { + Log.w(TAG, "$device no longer linked, will be recreated.") + SignalStore.unifiedpush.device = null + } + } + + UnifiedPushDistributor.registerApp() + + if (!UnifiedPushDistributor.checkIfActive() || endpoint == null) { + Log.e(TAG, "Distributor is not active or endpoint is missing.") + return RegistrationStatus.PENDING + } + + if (!airGapped && mollySocketUrl == null) { + Log.e(TAG, "Missing MollySocket URL.") + return RegistrationStatus.PENDING + } + + val device = SignalStore.unifiedpush.device ?: try { + MollySocketRepository.createDevice().also { device -> + SignalStore.unifiedpush.device = device + Log.d(TAG, "Created new MollySocket device: $device") + } + } catch (e: DeviceLimitExceededException) { + Log.e(TAG, "Device limit exceeded: ${e.max} total devices already.") + UnifiedPushNotificationBuilder(context).setNotificationDeviceLimitExceeded(e.max) + return RegistrationStatus.PENDING + } + + if (airGapped) { + return if (lastReceivedTime > 0) { + Log.d(TAG, "Air-gapped server: Device is registered.") + RegistrationStatus.REGISTERED + } else { + Log.d(TAG, "Air gapped server: Manual registration required!") + RegistrationStatus.PENDING + } + } + + Log.d(TAG, "Re-registering $device on MollySocket server...") + + val result = MollySocketRepository.registerDeviceOnServer( + url = mollySocketUrl!!, + device = device, + endpoint = endpoint, + ping = testPing, + ) + + Log.d(TAG, "Registration result: $result") + + return result.toRegistrationStatus() + } + + override fun onFailure() = Unit + + public override fun onShouldRetry(throwable: Exception): Boolean { + return throwable !is NonSuccessfulResponseCodeException + } + + override fun serialize(): ByteArray? { + return JsonJobData.Builder() + .putBoolean(KEY_PING, testPing) + .serialize() + } + + override fun getFactoryKey(): String = KEY + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): UnifiedPushRefreshJob { + val data = JsonJobData.deserialize(serializedData) + return UnifiedPushRefreshJob( + testPing = data.getBoolean(KEY_PING), + parameters = parameters, + ) + } + } + + companion object { + private val TAG = Log.tag(UnifiedPushRefreshJob::class.java) + + const val KEY = "UnifiedPushRefreshJob" + private const val KEY_PING = "ping" + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/model/ConnectionRequest.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/model/ConnectionRequest.kt new file mode 100644 index 0000000000..a21e22197f --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/model/ConnectionRequest.kt @@ -0,0 +1,30 @@ +package im.molly.unifiedpush.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class ConnectionRequest( + @JsonProperty("uuid") val uuid: String, + @JsonProperty("device_id") val deviceId: Int, + @JsonProperty("password") val password: String, + @JsonProperty("endpoint") val endpoint: String, + @JsonProperty("ping") val ping: Boolean, +) + +data class Response( + @JsonProperty("mollysocket") val mollySocket: ResponseMollySocket, +) + +data class ResponseMollySocket( + @JsonProperty("version") val version: String, + @JsonProperty("status") val status: ConnectionResult?, +) + +enum class ConnectionResult(private val formatted: String) { + OK("ok"), + FORBIDDEN("forbidden"), + INVALID_UUID("invalid_uuid"), + INVALID_ENDPOINT("invalid_endpoint"), + INTERNAL_ERROR("internal_error"); + + override fun toString(): String = formatted +} 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..c0b1a29d99 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt @@ -0,0 +1,35 @@ +package im.molly.unifiedpush.model + +data class MollySocketDevice( + val deviceId: Int, + val password: String, +) { + override fun toString(): String { + return "MollySocketDevice:$deviceId" + } +} + +enum class RegistrationStatus(val value: Int, val notifyUser: Boolean = false) { + UNKNOWN(0), + PENDING(1), + REGISTERED(2), + BAD_RESPONSE(3), + SERVER_ERROR(4), + FORBIDDEN_UUID(5, notifyUser = true), + FORBIDDEN_ENDPOINT(6, notifyUser = true); + + companion object { + fun fromValue(value: Int): RegistrationStatus? { + return entries.firstOrNull { it.value == value } + } + } +} + +fun ConnectionResult?.toRegistrationStatus():RegistrationStatus = when (this) { + ConnectionResult.OK -> RegistrationStatus.REGISTERED + ConnectionResult.INTERNAL_ERROR, + ConnectionResult.FORBIDDEN -> RegistrationStatus.SERVER_ERROR + ConnectionResult.INVALID_UUID -> RegistrationStatus.FORBIDDEN_UUID + ConnectionResult.INVALID_ENDPOINT -> RegistrationStatus.FORBIDDEN_ENDPOINT + null -> RegistrationStatus.BAD_RESPONSE +} 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..bbc5f9832e --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt @@ -0,0 +1,113 @@ +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.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 +import org.unifiedpush.android.connector.UnifiedPush + +class UnifiedPushReceiver : MessagingReceiver() { + + companion object { + private val TAG = Log.tag(UnifiedPushReceiver::class.java) + } + + private val executor = SerialMonoLifoExecutor(SignalExecutors.UNBOUNDED) + + private val appLocked + get() = KeyCachingService.isLocked() + + override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + Log.i(TAG, "onNewEndpoint($instance)") + if (!appLocked) { + refreshEndpoint(endpoint) + if (SignalStore.unifiedpush.airGapped) { + updateLastReceivedTime(0) + UnifiedPushNotificationBuilder(context).setNotificationEndpointChangedAirGapped() + } + } + } + + override fun onRegistrationFailed(context: Context, instance: String) { + // called when the registration is not possible, eg. no network + Log.w(TAG, "onRegistrationFailed($instance)") + if (!appLocked) { + 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.i(TAG, "onUnregistered($instance)") + UnifiedPush.forceRemoveDistributor(context) + if (!appLocked) { + refreshEndpoint(null) + } + } + + override fun onMessage(context: Context, message: ByteArray, instance: String) { + val msg = message.toString(Charsets.UTF_8) + + if (appLocked) { + onMessageLocked(context, msg) + } else { + updateLastReceivedTime(System.currentTimeMillis()) + 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.") + UnifiedPushNotificationBuilder(context).setNotificationTest() + } + + 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 updateLastReceivedTime(timestamp: Long) { + SignalStore.unifiedpush.lastReceivedTime = timestamp + } + + private fun refreshEndpoint(endpoint: String?): Boolean { + val stored = SignalStore.unifiedpush.endpoint + return if (endpoint != stored) { + SignalStore.unifiedpush.endpoint = endpoint + AppDependencies.jobManager.add(UnifiedPushRefreshJob()) + true + } else false + } +} 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..26e6ba00e0 --- /dev/null +++ b/app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.keyvalue + +import im.molly.unifiedpush.model.MollySocketDevice +import im.molly.unifiedpush.model.RegistrationStatus +import org.signal.core.util.logging.Log + +class UnifiedPushValues(store: KeyValueStore) : SignalStoreValues(store) { + + companion object { + private val TAG = Log.tag(UnifiedPushValues::class) + + private const val MOLLYSOCKET_DEVICE_ID = "mollysocket.deviceId" + private const val MOLLYSOCKET_PASSWORD = "mollysocket.passwd" + private const val MOLLYSOCKET_STATUS = "mollysocket.status" + private const val MOLLYSOCKET_AIR_GAPPED = "mollysocket.airGapped" + private const val MOLLYSOCKET_URL = "mollysocket.url" + private const val UNIFIEDPUSH_ENABLED = "up.enabled" + private const val UNIFIEDPUSH_ENDPOINT = "up.endpoint" + private const val UNIFIEDPUSH_LAST_RECEIVED_TIME = "up.lastRecvTime" + } + + override fun onFirstEverAppLaunch() = Unit + + override fun getKeysToIncludeInBackup() = emptyList() + + @get:JvmName("isEnabled") + var enabled: Boolean by booleanValue(UNIFIEDPUSH_ENABLED, false) + + var device: MollySocketDevice? + get() { + return MollySocketDevice( + deviceId = getInteger(MOLLYSOCKET_DEVICE_ID, 0), + password = getString(MOLLYSOCKET_PASSWORD, null) ?: return null, + ) + } + set(device) { + store.beginWrite() + .putInteger(MOLLYSOCKET_DEVICE_ID, device?.deviceId ?: 0) + .putString(MOLLYSOCKET_PASSWORD, device?.password) + .apply() + } + + var registrationStatus: RegistrationStatus + get() = RegistrationStatus.fromValue(getInteger(MOLLYSOCKET_STATUS, -1)) ?: RegistrationStatus.UNKNOWN + set(status) { + putInteger(MOLLYSOCKET_STATUS, status.value) + } + + var endpoint: String? by stringValue(UNIFIEDPUSH_ENDPOINT, null) + + var airGapped: Boolean by booleanValue(MOLLYSOCKET_AIR_GAPPED, false) + + var mollySocketUrl: String? by stringValue(MOLLYSOCKET_URL, null) + + var lastReceivedTime: Long by longValue(UNIFIEDPUSH_LAST_RECEIVED_TIME, 0) + + val isAvailableOrAirGapped: Boolean + get() = enabled && registrationStatus == RegistrationStatus.REGISTERED +} diff --git a/dependencies.gradle.kts b/dependencies.gradle.kts index 16dea4374d..86d2852b2d 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 9872e22a5b..9083751405 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -8434,6 +8434,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -8514,6 +8522,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -9591,6 +9604,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + From b12896871edb2f18c5a23275de2d9d73087cf63a Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 16 Nov 2024 15:54:41 +0000 Subject: [PATCH 03/13] Bump unifiedpush libs --- app/build.gradle.kts | 1 + .../NotificationsSettingsViewModel.kt | 2 +- .../unifiedpush/UnifiedPushDistributor.kt | 25 ++++++++----- .../receiver/UnifiedPushReceiver.kt | 16 ++++++--- dependencies.gradle.kts | 4 +-- gradle/verification-metadata.xml | 36 +++++++++++++++++++ 6 files changed, 67 insertions(+), 17 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cf466d3608..db664f6b82 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -538,6 +538,7 @@ dependencies { "fossImplementation"("org.osmdroid:osmdroid-android:6.1.16") implementation(libs.unifiedpush.connector) { exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib") + exclude(group = "com.google.protobuf", module = "protobuf-java") } implementation(libs.unifiedpush.connector.ui) { exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib") 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 4d3a45ec8d..82d0447efc 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 @@ -121,7 +121,7 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer } fun initializeUnifiedPushDistributor() { - UnifiedPushDistributor.selectFirstDistributor() + UnifiedPushDistributor.selectCurrentOrDefaultDistributor() } fun setPlayServicesErrorCode(errorCode: Int?) { diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt index 2449f55714..dc2afc4fa9 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt @@ -2,9 +2,8 @@ package im.molly.unifiedpush import android.content.Context import org.thoughtcrime.securesms.dependencies.AppDependencies -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.SelectDistributorDialogsBuilder import org.unifiedpush.android.connector.ui.UnifiedPushFunctions object UnifiedPushDistributor { @@ -19,26 +18,34 @@ object UnifiedPushDistributor { UnifiedPush.unregisterApp(AppDependencies.application) } - fun selectFirstDistributor() { + fun selectCurrentOrDefaultDistributor() { val context = AppDependencies.application - UnifiedPush.getSavedDistributor(context) - ?: UnifiedPush.getDistributors(context).firstOrNull()?.also { - UnifiedPush.saveDistributor(context, it) + UnifiedPush.tryUseCurrentOrDefaultDistributor(context) { success -> + if (!success) { + // If there are multiple distributors installed, but none of them follow the last + // specifications, we fall back to the first we found. + UnifiedPush.getDistributors(context).firstOrNull()?.also { + UnifiedPush.saveDistributor(context, it) + } } + } } @JvmStatic fun showSelectDistributorDialog(context: Context) { - SelectDistributorDialogBuilder( + SelectDistributorDialogsBuilder( 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) + override fun tryUseDefaultDistributor(callback: (Boolean) -> Unit) = UnifiedPush.tryUseDefaultDistributor(context, callback) } - ).show() + ).apply { + mayUseCurrent = false + mayUseDefault = false + }.run() } fun checkIfActive(): Boolean { diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt index bbc5f9832e..7870e4179b 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt @@ -14,8 +14,11 @@ 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.FailedReason import org.unifiedpush.android.connector.MessagingReceiver import org.unifiedpush.android.connector.UnifiedPush +import org.unifiedpush.android.connector.data.PushEndpoint +import org.unifiedpush.android.connector.data.PushMessage class UnifiedPushReceiver : MessagingReceiver() { @@ -28,10 +31,10 @@ class UnifiedPushReceiver : MessagingReceiver() { private val appLocked get() = KeyCachingService.isLocked() - override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { + override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { Log.i(TAG, "onNewEndpoint($instance)") if (!appLocked) { - refreshEndpoint(endpoint) + refreshEndpoint(endpoint.url) if (SignalStore.unifiedpush.airGapped) { updateLastReceivedTime(0) UnifiedPushNotificationBuilder(context).setNotificationEndpointChangedAirGapped() @@ -39,10 +42,13 @@ class UnifiedPushReceiver : MessagingReceiver() { } } - override fun onRegistrationFailed(context: Context, instance: String) { + override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) { // called when the registration is not possible, eg. no network Log.w(TAG, "onRegistrationFailed($instance)") if (!appLocked) { + // TODO when `reason` is INTERNAL_ERROR, try to register again _one time_ + // TODO when `reason` is ACTION_REQUIRED, tell the distributor requires a user interaction + // TODO when `reason` is NETWORK, tell to try again when network is back, or implement it. UnifiedPushNotificationBuilder(context).setNotificationRegistrationFailed() } } @@ -57,8 +63,8 @@ class UnifiedPushReceiver : MessagingReceiver() { } } - override fun onMessage(context: Context, message: ByteArray, instance: String) { - val msg = message.toString(Charsets.UTF_8) + override fun onMessage(context: Context, message: PushMessage, instance: String) { + val msg = message.content.toString(Charsets.UTF_8) if (appLocked) { onMessageLocked(context, msg) diff --git a/dependencies.gradle.kts b/dependencies.gradle.kts index 86d2852b2d..95b15ca645 100644 --- a/dependencies.gradle.kts +++ b/dependencies.gradle.kts @@ -137,8 +137,8 @@ dependencyResolutionManagement { 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") + library("unifiedpush-connector", "org.unifiedpush.android:connector:3.0.0-rc2") + library("unifiedpush-connector-ui", "org.unifiedpush.android:connector-ui:1.1.0-rc3") // Third Party library("greenrobot-eventbus", "org.greenrobot:eventbus:3.0.0") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 9083751405..0c66c48600 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -5445,6 +5445,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + @@ -5558,6 +5568,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -5774,6 +5789,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -9612,6 +9632,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + @@ -9620,6 +9648,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + From a688a8d09a5ed40a9d60d9b7d3011757b6dcd398 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 19 Nov 2024 08:11:39 +0000 Subject: [PATCH 04/13] Link MollySocket with a QR code --- app/src/main/AndroidManifest.xml | 2 + .../NotificationsSettingsFragment.kt | 15 +- .../NotificationsSettingsViewModel.kt | 5 +- app/src/main/res/values/strings2.xml | 5 + .../unifiedpush/UnifiedPushDistributor.kt | 4 + .../app/notifications/MollySocketLinkData.kt | 49 +++++ .../MollySocketQrScanRepository.kt | 84 ++++++++ .../notifications/MollySocketQrScanScreen.kt | 142 +++++++++++++ .../MollySocketQrScannerActivity.kt | 201 ++++++++++++++++++ .../MollySocketQrScannerViewModel.kt | 59 +++++ .../notifications/QrImageSelectionActivity.kt | 65 ++++++ .../app/notifications/QrScanResult.kt | 22 ++ .../UnifiedPushSettingsViewModel.kt | 8 +- .../securesms/keyvalue/UnifiedPushValues.kt | 3 + 14 files changed, 658 insertions(+), 6 deletions(-) create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketLinkData.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrImageSelectionActivity.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9f1ed31a36..ada24b0f81 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1069,6 +1069,8 @@ android:windowSoftInputMode="adjustResize" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="false"/> + + = registerForActivityResult(MollySocketQrScannerActivity.Contract()) { data -> + if (data != null) { + viewModel.initializeUnifiedPushDistributor(data.type == "airgapped", data.url, data.vapid) + viewModel.setPreferredNotificationMethod(NotificationDeliveryMethod.UNIFIEDPUSH) + navigateToUnifiedPushSettings() + } + } + private val layoutManager: LinearLayoutManager? get() = recyclerView?.layoutManager as? LinearLayoutManager @@ -407,9 +418,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__ .setTitle(R.string.NotificationsSettingsFragment__mollysocket_server) .setMessage(R.string.NotificationsSettingsFragment__to_use_unifiedpush_you_need_a_mollysocket_server) .setPositiveButton(R.string.yes) { _, _ -> - viewModel.initializeUnifiedPushDistributor() - viewModel.setPreferredNotificationMethod(method) - navigateToUnifiedPushSettings() + qrScanLauncher.launch() } .setNegativeButton(R.string.no, null) .setNeutralButton(R.string.LearnMoreTextView_learn_more) { _, _ -> 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 82d0447efc..6715139a12 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 @@ -120,7 +120,10 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer refresh() } - fun initializeUnifiedPushDistributor() { + fun initializeUnifiedPushDistributor(airgapped: Boolean, url: String?, vapid: String) { + SignalStore.unifiedpush.airGapped = airgapped + SignalStore.unifiedpush.mollySocketUrl = url + SignalStore.unifiedpush.mollySocketVapid = vapid UnifiedPushDistributor.selectCurrentOrDefaultDistributor() } diff --git a/app/src/main/res/values/strings2.xml b/app/src/main/res/values/strings2.xml index b06ee1b797..f95c518cfe 100644 --- a/app/src/main/res/values/strings2.xml +++ b/app/src/main/res/values/strings2.xml @@ -169,4 +169,9 @@ FCM (Google Play Services) WebSocket WebSocket Service + Try scanning another image containing a MollySocket QR code. + QR code not found + The QR code was invalid. + MollySocket server not found at %s + Experienced a network error. Please try again. diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt index dc2afc4fa9..eeba8b4256 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt @@ -1,6 +1,7 @@ package im.molly.unifiedpush import android.content.Context +import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.AppDependencies import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.ui.SelectDistributorDialogsBuilder @@ -8,6 +9,8 @@ import org.unifiedpush.android.connector.ui.UnifiedPushFunctions object UnifiedPushDistributor { + private const val TAG = "UnifiedPushDistributor" + @JvmStatic fun registerApp() { UnifiedPush.registerApp(AppDependencies.application) @@ -24,6 +27,7 @@ object UnifiedPushDistributor { if (!success) { // If there are multiple distributors installed, but none of them follow the last // specifications, we fall back to the first we found. + Log.d(TAG, "Multiple distributors found, none of them follow last specifications") UnifiedPush.getDistributors(context).firstOrNull()?.also { UnifiedPush.saveDistributor(context, it) } diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketLinkData.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketLinkData.kt new file mode 100644 index 0000000000..4362ffc03d --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketLinkData.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package im.molly.unifiedpush.components.settings.app.notifications + +import java.net.URI +import java.util.Optional + +data class MollySocketLinkData( + val vapid: String, + val type: String, + val url: String? +) { + companion object { + fun parse(url: String): Optional { + val uri = URI(url) + val params: MutableMap = emptyMap().toMutableMap() + uri.rawQuery.split('&').forEach { rawParam -> + rawParam.split('=').let { + params[it.getOrElse(0) { "" }] = URI("#${it.getOrElse(1) { "" }}").fragment + } + } + val vapid = params["vapid"] + val type = params["type"] + val parsedUrl = params["url"] + if ( + uri.scheme == "mollysocket" + && uri.authority == "link" + && vapid?.length == 87 + && ( + type == "airgapped" + || (type == "webserver" && URI(parsedUrl).scheme == "https") + ) + ) { + return Optional.of( + MollySocketLinkData( + vapid = vapid, + type = type, + url = parsedUrl + ) + ) + } else { + return Optional.empty() + } + } + } +} \ No newline at end of file diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt new file mode 100644 index 0000000000..7314e8fcf0 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package im.molly.unifiedpush.components.settings.app.notifications + +import android.content.Context +import android.net.Uri +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DecodeFormat +import im.molly.unifiedpush.MollySocketRepository +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.signal.core.util.logging.Log +import org.signal.core.util.toOptional +import org.signal.qr.QrProcessor +import kotlin.jvm.optionals.getOrNull + + +/** + * A collection of functions to help with scanning QR codes for MollySocket. + */ +object MollySocketQrScanRepository { + private const val TAG = "MollySocketQrScanRepository" + + /** + * Given a URL, will attempt to lookup MollySocket informations, coercing it to a standard set of [QrScanResult]s. + */ + fun lookupUrl(url: String): Single { + val data = MollySocketLinkData.parse(url).getOrNull() + if(data == null || (data.type == "webserver" && data.url == null)) { + return Single.just(QrScanResult.InvalidData) + } + if (data.type == "airgapped") { + return Single.just(QrScanResult.Success(data)) + } + return checkMollySocketServer(data.url ?: "").map { found -> + if (found) { + QrScanResult.Success(data) + } else { + // TODO add network check + QrScanResult.NotFound( + url = data.url ?: "" + ) + } + }.subscribeOn(Schedulers.io()) + } + + private fun checkMollySocketServer(url: String): Single { + return Single + .fromCallable { + runCatching { + MollySocketRepository.discoverMollySocketServer(url.toHttpUrl()) + }.getOrElse { e -> + Log.e(TAG, "Cannot discover MollySocket", e) + false + } + }.subscribeOn(Schedulers.io()) + } + + /** + * Given a URI pointing to an image that may contain a username QR code, this will attempt to lookup the username, coercing it to a standard set of [QrScanResult]s. + */ + fun scanImageUriForQrCode(context: Context, uri: Uri): Single { + val loadBitmap = Glide.with(context) + .asBitmap() + .format(DecodeFormat.PREFER_ARGB_8888) + .load(uri) + .submit() + + return Single.fromFuture(loadBitmap) + .map { QrProcessor().getScannedData(it).toOptional() } + .flatMap { + if (it.isPresent) { + lookupUrl(it.get()) + } else { + Single.just(QrScanResult.QrNotFound) + } + } + .subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt new file mode 100644 index 0000000000..a9224bc6f9 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt @@ -0,0 +1,142 @@ +package im.molly.unifiedpush.components.settings.app.notifications + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.LifecycleOwner +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import org.signal.core.ui.Dialogs +import org.signal.core.ui.theme.SignalTheme +import org.signal.qr.QrScannerView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist +import org.thoughtcrime.securesms.qr.QrScanScreens +import java.util.concurrent.TimeUnit + +/** + * A screen that allows you to scan a QR code to start a chat. + */ +@Composable +fun MollySocketQrScanScreen( + lifecycleOwner: LifecycleOwner, + disposables: CompositeDisposable, + qrScanResult: QrScanResult?, + onQrCodeScanned: (String) -> Unit, + onQrResultHandled: () -> Unit, + onOpenCameraClicked: () -> Unit, + onOpenGalleryClicked: () -> Unit, + onDataFound: (MollySocketLinkData) -> Unit, + hasCameraPermission: Boolean, + modifier: Modifier = Modifier +) { + when (qrScanResult) { + QrScanResult.InvalidData -> { + QrScanResultDialog(message = stringResource(R.string.MollySocketLink_the_qr_code_was_invalid), onDismiss = onQrResultHandled) + } + + QrScanResult.NetworkError -> { + QrScanResultDialog(message = stringResource(R.string.MollySocketLink_experienced_a_network_error_please_try_again), onDismiss = onQrResultHandled) + } + + QrScanResult.QrNotFound -> { + QrScanResultDialog( + title = stringResource(R.string.MollySocketLink_qr_code_not_found), + message = stringResource(R.string.MollySocketLink_try_scanning_another_image_containing_a_mollysocket_qr_code), + onDismiss = onQrResultHandled + ) + } + + is QrScanResult.NotFound -> { + QrScanResultDialog(message = stringResource(R.string.MollySocketLink_mollysocket_server_not_found_at_s, qrScanResult.url), onDismiss = onQrResultHandled) + } + + is QrScanResult.Success -> { + onDataFound(qrScanResult.data) + } + + null -> {} + } + + Column( + modifier = modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f, true) + ) { + QrScanScreens.QrScanScreen( + factory = { context -> + val view = QrScannerView(context) + disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data -> + onQrCodeScanned(data) + } + view + }, + update = { view -> + view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted()) + }, + hasPermission = hasCameraPermission, + onRequestPermissions = onOpenCameraClicked, + qrHeaderLabelString = "" + ) + FloatingActionButton( + shape = CircleShape, + containerColor = SignalTheme.colors.colorSurface1, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp), + onClick = onOpenGalleryClicked + ) { + Image( + painter = painterResource(id = R.drawable.symbol_album_24), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.UsernameLinkSettings_qr_scan_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun QrScanResultDialog(title: String? = null, message: String, onDismiss: () -> Unit) { + Dialogs.SimpleMessageDialog( + title = title, + message = message, + dismiss = stringResource(id = android.R.string.ok), + onDismiss = onDismiss + ) +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt new file mode 100644 index 0000000000..69809e9618 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package im.molly.unifiedpush.components.settings.app.notifications + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.LifecycleOwner +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.signal.core.ui.Dialogs +import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.concurrent.LifecycleDisposable +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.permissions.PermissionCompat +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.util.DynamicTheme + +/** + * Prompts the user to scan a MollySocket QR code. Uses the activity result to communicate the recipient that was found, or null if no valid deeplink were scanned. + * See [Contract]. + */ +class MollySocketQrScannerActivity : AppCompatActivity() { + + companion object { + private const val KEY_URL = "url" + private const val KEY_TYPE = "type" + private const val KEY_VAPID = "vapid" + } + + private val viewModel: MollySocketQrScannerViewModel by viewModels() + private val disposables = LifecycleDisposable() + + @SuppressLint("MissingSuperCall") + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + disposables.bindTo(this) + + val galleryLauncher = registerForActivityResult(QrImageSelectionActivity.Contract()) { uri -> + if (uri != null) { + viewModel.onQrImageSelected(this, uri) + } + } + + setContent { + val galleryPermissionState: MultiplePermissionsState = rememberMultiplePermissionsState(permissions = PermissionCompat.forImages().toList()) { grants -> + if (grants.values.all { it }) { + galleryLauncher.launch(Unit) + } else { + Toast.makeText(this, R.string.ChatWallpaperPreviewActivity__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT).show() + } + } + + val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) + val state by viewModel.state + + SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) { + Content( + lifecycleOwner = this, + diposables = disposables.disposables, + state = state, + galleryPermissionsState = galleryPermissionState, + cameraPermissionState = cameraPermissionState, + onQrScanned = { url -> viewModel.onQrScanned(url) }, + onQrResultHandled = { + finish() + }, + onOpenCameraClicked = { askCameraPermissions() }, + onOpenGalleryClicked = { + if (galleryPermissionState.allPermissionsGranted) { + galleryLauncher.launch(Unit) + } else { + galleryPermissionState.launchMultiplePermissionRequest() + } + }, + onDataFound = { data -> + val intent = Intent().apply { + putExtra(KEY_VAPID, data.vapid) + putExtra(KEY_URL, data.url) + putExtra(KEY_TYPE, data.type) + } + setResult(RESULT_OK, intent) + finish() + }, + onBackNavigationPressed = { + finish() + } + ) + } + } + } + + private fun askCameraPermissions() { + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_scan_qr_codes, supportFragmentManager) + .onAnyDenied { Toast.makeText(this, R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() } + .execute() + } + + class Contract : ActivityResultContract() { + override fun createIntent(context: Context, input: Unit): Intent { + return Intent(context, MollySocketQrScannerActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): MollySocketLinkData? { + return intent?.let { + MollySocketLinkData( + type = intent.getStringExtra(KEY_TYPE) ?: return null, + url = intent.getStringExtra(KEY_URL), + vapid = intent.getStringExtra(KEY_VAPID) ?: return null + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Content( + lifecycleOwner: LifecycleOwner, + diposables: CompositeDisposable, + state: MollySocketQrScannerViewModel.ScannerState, + galleryPermissionsState: MultiplePermissionsState, + cameraPermissionState: PermissionState, + onQrScanned: (String) -> Unit, + onQrResultHandled: () -> Unit, + onOpenCameraClicked: () -> Unit, + onOpenGalleryClicked: () -> Unit, + onDataFound: (MollySocketLinkData) -> Unit, + onBackNavigationPressed: () -> Unit +) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = {}, + navigationIcon = { + IconButton( + onClick = onBackNavigationPressed + ) { + Icon( + painter = painterResource(R.drawable.symbol_x_24), + contentDescription = stringResource(android.R.string.cancel) + ) + } + } + ) + } + ) { contentPadding -> + MollySocketQrScanScreen( + lifecycleOwner = lifecycleOwner, + disposables = diposables, + qrScanResult = state.qrScanResult, + onQrCodeScanned = onQrScanned, + onQrResultHandled = onQrResultHandled, + onOpenCameraClicked = onOpenCameraClicked, + onOpenGalleryClicked = onOpenGalleryClicked, + onDataFound = onDataFound, + hasCameraPermission = cameraPermissionState.status.isGranted, + modifier = Modifier.padding(contentPadding) + ) + + if (state.indeterminateProgress) { + Dialogs.IndeterminateProgressDialog() + } + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt new file mode 100644 index 0000000000..bcab4952c9 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package im.molly.unifiedpush.components.settings.app.notifications + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy + +class MollySocketQrScannerViewModel : ViewModel() { + + private val _state = mutableStateOf(ScannerState(qrScanResult = null, indeterminateProgress = false)) + val state: State = _state + + private val disposables = CompositeDisposable() + + fun onQrScanned(url: String) { + _state.value = state.value.copy(indeterminateProgress = true) + + disposables += MollySocketQrScanRepository.lookupUrl(url) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { result -> + _state.value = _state.value.copy( + qrScanResult = result, + indeterminateProgress = false + ) + } + } + + fun onQrImageSelected(context: Context, uri: Uri) { + _state.value = state.value.copy(indeterminateProgress = true) + + disposables += MollySocketQrScanRepository.scanImageUriForQrCode(context, uri) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { result -> + _state.value = _state.value.copy( + qrScanResult = result, + indeterminateProgress = false + ) + } + } + + override fun onCleared() { + disposables.clear() + } + + data class ScannerState( + val qrScanResult: QrScanResult?, + val indeterminateProgress: Boolean + ) +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrImageSelectionActivity.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrImageSelectionActivity.kt new file mode 100644 index 0000000000..c1d439db7a --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrImageSelectionActivity.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package im.molly.unifiedpush.components.settings.app.notifications + + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.result.contract.ActivityResultContract +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.v2.gallery.MediaGalleryFragment + +/** + * Select qr code from gallery instead of using camera. Used in usernames and when linking devices + */ +class QrImageSelectionActivity : AppCompatActivity(), MediaGalleryFragment.Callbacks { + + override fun attachBaseContext(newBase: Context) { + delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES + super.attachBaseContext(newBase) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN) + setContentView(R.layout.username_qr_image_selection_activity) + } + + @SuppressLint("LogTagInlined") + override fun onMediaSelected(media: Media) { + setResult(RESULT_OK, Intent().setData(media.uri)) + finish() + } + + override fun onToolbarNavigationClicked() { + setResult(RESULT_CANCELED) + finish() + } + + override fun isCameraEnabled() = false + override fun isMultiselectEnabled() = false + + class Contract : ActivityResultContract() { + override fun createIntent(context: Context, input: Unit): Intent { + return Intent(context, QrImageSelectionActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + return if (resultCode == RESULT_OK) { + intent?.data + } else { + null + } + } + } +} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt new file mode 100644 index 0000000000..93c96ec9a7 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package im.molly.unifiedpush.components.settings.app.notifications + + +/** + * Result of taking data from the QR scanner and trying to resolve it to a recipient. + */ +sealed class QrScanResult { + class Success(val data: MollySocketLinkData) : QrScanResult() + + class NotFound(val url: String) : QrScanResult() + + object InvalidData : QrScanResult() + + object NetworkError : QrScanResult() + + object QrNotFound : QrScanResult() +} \ No newline at end of file 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 index 77bfc96d0c..e4783f745e 100644 --- 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 @@ -91,10 +91,10 @@ class UnifiedPushSettingsViewModel(private val application: Application) : ViewM refreshAndUpdateRegistration() } - fun setMollySocketUrl(url: String): Boolean { + fun setMollySocketUrl(url: String?): Boolean { SignalStore.unifiedpush.lastReceivedTime = 0 - val normalizedUrl = if (url.lastOrNull() != '/') "$url/" else url + val normalizedUrl = if (url?.lastOrNull() != '/') "$url/" else url ?: "" val httpUrl = normalizedUrl.toHttpUrlOrNull() return if (httpUrl != null) { @@ -144,6 +144,10 @@ class UnifiedPushSettingsViewModel(private val application: Application) : ViewM refreshAndUpdateRegistration(pingOnRegister = true) } + fun setMollySocketVapid(vapid: String) { + SignalStore.unifiedpush.mollySocketVapid = vapid + } + 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/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt b/app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt index 26e6ba00e0..baf382e6a8 100644 --- a/app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt +++ b/app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt @@ -14,6 +14,7 @@ class UnifiedPushValues(store: KeyValueStore) : SignalStoreValues(store) { private const val MOLLYSOCKET_STATUS = "mollysocket.status" private const val MOLLYSOCKET_AIR_GAPPED = "mollysocket.airGapped" private const val MOLLYSOCKET_URL = "mollysocket.url" + private const val MOLLYSOCKET_VAPID = "mollysocket.vapid" private const val UNIFIEDPUSH_ENABLED = "up.enabled" private const val UNIFIEDPUSH_ENDPOINT = "up.endpoint" private const val UNIFIEDPUSH_LAST_RECEIVED_TIME = "up.lastRecvTime" @@ -52,6 +53,8 @@ class UnifiedPushValues(store: KeyValueStore) : SignalStoreValues(store) { var mollySocketUrl: String? by stringValue(MOLLYSOCKET_URL, null) + var mollySocketVapid: String? by stringValue(MOLLYSOCKET_VAPID, null) + var lastReceivedTime: Long by longValue(UNIFIEDPUSH_LAST_RECEIVED_TIME, 0) val isAvailableOrAirGapped: Boolean From 495bc87fc756d8a14d8ddc1670e4e5c8e8595ade Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 19 Nov 2024 09:56:17 +0000 Subject: [PATCH 05/13] Fix selection of default distributor --- app/src/main/AndroidManifest.xml | 1 + .../NotificationsSettingsFragment.kt | 18 +++++- .../NotificationsSettingsViewModel.kt | 7 ++- ...ifiedPushDefaultDistributorLinkActivity.kt | 60 +++++++++++++++++++ .../unifiedpush/UnifiedPushDistributor.kt | 15 +---- 5 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ada24b0f81..b04c78cb58 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1071,6 +1071,7 @@ android:exported="false"/> + = registerForActivityResult( + UnifiedPushDefaultDistributorLinkActivity.Contract() + ) { success -> + if (success != true) { + // If there are no distributors or + // if there are multiple distributors installed, but none of them follow the last + // specifications, + // we try to fall back to the first we found. + viewModel.selectFirstDistributor() + } + navigateToUnifiedPushSettings() + } + private val qrScanLauncher: ActivityResultLauncher = registerForActivityResult(MollySocketQrScannerActivity.Contract()) { data -> if (data != null) { - viewModel.initializeUnifiedPushDistributor(data.type == "airgapped", data.url, data.vapid) + viewModel.initializeMollySocket(data.type == "airgapped", data.url, data.vapid) viewModel.setPreferredNotificationMethod(NotificationDeliveryMethod.UNIFIEDPUSH) - navigateToUnifiedPushSettings() + linkDefaultDistributorLauncher.launch() } } 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 6715139a12..dd5c5a25cc 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 @@ -120,11 +120,14 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer refresh() } - fun initializeUnifiedPushDistributor(airgapped: Boolean, url: String?, vapid: String) { + fun selectFirstDistributor() { + UnifiedPushDistributor.selectFirstDistributor() + } + + fun initializeMollySocket(airgapped: Boolean, url: String?, vapid: String) { SignalStore.unifiedpush.airGapped = airgapped SignalStore.unifiedpush.mollySocketUrl = url SignalStore.unifiedpush.mollySocketVapid = vapid - UnifiedPushDistributor.selectCurrentOrDefaultDistributor() } fun setPlayServicesErrorCode(errorCode: Int?) { diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt new file mode 100644 index 0000000000..a27a20fc3b --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package im.molly.unifiedpush + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContract +import androidx.appcompat.app.AppCompatActivity +import org.signal.core.util.logging.Log +import org.unifiedpush.android.connector.LinkActivityHelper + +class UnifiedPushDefaultDistributorLinkActivity : AppCompatActivity() { + private val helper = LinkActivityHelper(this) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (!helper.startLinkActivityForResult()) { + Log.d(TAG, "No distributor with link activity found.") + setResult(RESULT_OK) + finish() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (helper.onLinkActivityResult(requestCode, resultCode, data)) { + // The distributor is saved, you can request registrations with UnifiedPush.registerApp now + Log.d(TAG, "Found a distributor with link activity found.") + val intent = Intent().putExtra(KEY_FOUND, true) + setResult(RESULT_OK, intent) + } else { + // An error occurred, consider no distributor found for the moment + Log.d(TAG, "Found a distributor with link activity found but an error occurred.") + setResult(RESULT_OK) + } + finish() + } + + class Contract : ActivityResultContract() { + override fun createIntent(context: Context, input: Unit): Intent { + return Intent(context, UnifiedPushDefaultDistributorLinkActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Boolean? { + return intent?.let { + intent.getBooleanExtra(KEY_FOUND, false) + } + } + } + + companion object { + private const val KEY_FOUND = "found" + private const val TAG = "UnifiedPushDefaultDistributorLinkActivity" + } +} \ No newline at end of file diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt index eeba8b4256..3000f8de79 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt @@ -1,7 +1,6 @@ package im.molly.unifiedpush import android.content.Context -import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.AppDependencies import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.ui.SelectDistributorDialogsBuilder @@ -9,7 +8,6 @@ import org.unifiedpush.android.connector.ui.UnifiedPushFunctions object UnifiedPushDistributor { - private const val TAG = "UnifiedPushDistributor" @JvmStatic fun registerApp() { @@ -21,17 +19,10 @@ object UnifiedPushDistributor { UnifiedPush.unregisterApp(AppDependencies.application) } - fun selectCurrentOrDefaultDistributor() { + fun selectFirstDistributor() { val context = AppDependencies.application - UnifiedPush.tryUseCurrentOrDefaultDistributor(context) { success -> - if (!success) { - // If there are multiple distributors installed, but none of them follow the last - // specifications, we fall back to the first we found. - Log.d(TAG, "Multiple distributors found, none of them follow last specifications") - UnifiedPush.getDistributors(context).firstOrNull()?.also { - UnifiedPush.saveDistributor(context, it) - } - } + UnifiedPush.getDistributors(context).firstOrNull()?.also { + UnifiedPush.saveDistributor(context, it) } } From 37c6e34827042a9616024eee8cf4fb10ffe0af46 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 19 Nov 2024 14:12:23 +0000 Subject: [PATCH 06/13] Fix mollysocket refused status --- app/src/main/res/values/strings2.xml | 5 ++++- .../unifiedpush/UnifiedPushNotificationBuilder.kt | 13 +++++++++++-- .../notifications/UnifiedPushSettingsFragment.kt | 1 + .../unifiedpush/jobs/UnifiedPushRefreshJob.kt | 10 ++++++---- .../molly/unifiedpush/model/MollySocketDevice.kt | 14 +++++++++----- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values/strings2.xml b/app/src/main/res/values/strings2.xml index f95c518cfe..e1d4182d7a 100644 --- a/app/src/main/res/values/strings2.xml +++ b/app/src/main/res/values/strings2.xml @@ -157,12 +157,15 @@ Waiting for UnifiedPush distributor response The endpoint is forbidden by the server No UnifiedPush distributor installed + The registration is no longer valid No distributor app selected Enable if your MollySocket server can\'t be reached from the internet. You\'ll need to manually add your account to the server. "MollySocket server not found. Please check the URL and try again." You\'ve reached the limit of %d linked devices. To link your MollySocket server, please remove a device first. Your UnifiedPush endpoint has changed. You must update your connection on MollySocket. - Your registration on MollySocket is no longer valid. Please remove the linked device and try registering again. + Your registration on MollySocket is no longer valid. Please remove the linked device and try registering again. + Your MollySocket server is configured to refuse this account. + Your MollySocket server is configured to refuse this push server. Registration with your UnifiedPush distributor failed. This could be due to a network issue or a missing requirement from your distributor. This is a test notification from the MollySocket server. UnifiedPush diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt index b8bc8dbc41..7a76c06c2a 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt @@ -52,8 +52,17 @@ class UnifiedPushNotificationBuilder(val context: Context) { notify(NOTIFICATION_ID, context.getString(R.string.UnifiedPushNotificationBuilder__mollysocket_device_limit_hit, deviceLimit - 1)) } - fun setNotificationMollySocketRegistrationChanged() { - notify(NOTIFICATION_ID, context.getString(R.string.UnifiedPushNotificationBuilder__mollysocket_registration_changed)) + + fun setNotificationMollySocketForbiddenEndpoint() { + notify(NOTIFICATION_ID, context.getString(R.string.UnifiedPushNotificationBuilder__mollysocket_forbidden_endpoint)) + } + + fun setNotificationMollySocketForbiddenUuid() { + notify(NOTIFICATION_ID, context.getString(R.string.UnifiedPushNotificationBuilder__mollysocket_forbidden_uuid)) + } + + fun setNotificationMollySocketForbiddenPassword() { + notify(NOTIFICATION_ID, context.getString(R.string.UnifiedPushNotificationBuilder__mollysocket_forbidden_password)) } fun setNotificationEndpointChangedAirGapped() { 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 index f583b5408a..02a63dec5c 100644 --- 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 @@ -202,6 +202,7 @@ class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDel RegistrationStatus.SERVER_ERROR -> R.string.UnifiedPushSettingsFragment__status_summary_bad_response RegistrationStatus.REGISTERED -> android.R.string.ok + RegistrationStatus.FORBIDDEN_PASSWORD -> R.string.UnifiedPushSettingsFragment__status_summary_bad_password RegistrationStatus.FORBIDDEN_UUID -> R.string.UnifiedPushSettingsFragment__status_summary_forbidden_uuid RegistrationStatus.FORBIDDEN_ENDPOINT -> R.string.UnifiedPushSettingsFragment__status_summary_forbidden_endpoint } diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt index a56e1bdea5..f5388497bb 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt @@ -67,10 +67,12 @@ class UnifiedPushRefreshJob private constructor( SignalStore.unifiedpush.registrationStatus = newStatus } - if (newStatus == RegistrationStatus.REGISTERED) { - UnifiedPushNotificationBuilder(context).clearAlerts() - } else if (newStatus.notifyUser) { - UnifiedPushNotificationBuilder(context).setNotificationMollySocketRegistrationChanged() + when (newStatus) { + RegistrationStatus.REGISTERED -> UnifiedPushNotificationBuilder(context).clearAlerts() + RegistrationStatus.FORBIDDEN_ENDPOINT -> UnifiedPushNotificationBuilder(context).setNotificationMollySocketForbiddenEndpoint() + RegistrationStatus.FORBIDDEN_UUID -> UnifiedPushNotificationBuilder(context).setNotificationMollySocketForbiddenUuid() + RegistrationStatus.FORBIDDEN_PASSWORD -> UnifiedPushNotificationBuilder(context).setNotificationMollySocketForbiddenPassword() + else -> {} } } catch (t: Throwable) { Log.e(TAG, "Error checking registration status", t) diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt index c0b1a29d99..18cef23012 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt @@ -9,14 +9,18 @@ data class MollySocketDevice( } } -enum class RegistrationStatus(val value: Int, val notifyUser: Boolean = false) { +enum class RegistrationStatus(val value: Int) { UNKNOWN(0), PENDING(1), REGISTERED(2), BAD_RESPONSE(3), SERVER_ERROR(4), - FORBIDDEN_UUID(5, notifyUser = true), - FORBIDDEN_ENDPOINT(6, notifyUser = true); + /** The UUID is forbidden by the config of MollySocket */ + FORBIDDEN_UUID(5), + /** The endpoint is forbidden by the config of MollySocket */ + FORBIDDEN_ENDPOINT(6), + /** The account+password doesn't work anymore, and returns forbidden by Signal server */ + FORBIDDEN_PASSWORD(7); companion object { fun fromValue(value: Int): RegistrationStatus? { @@ -27,8 +31,8 @@ enum class RegistrationStatus(val value: Int, val notifyUser: Boolean = false) { fun ConnectionResult?.toRegistrationStatus():RegistrationStatus = when (this) { ConnectionResult.OK -> RegistrationStatus.REGISTERED - ConnectionResult.INTERNAL_ERROR, - ConnectionResult.FORBIDDEN -> RegistrationStatus.SERVER_ERROR + ConnectionResult.INTERNAL_ERROR -> RegistrationStatus.SERVER_ERROR + ConnectionResult.FORBIDDEN -> RegistrationStatus.FORBIDDEN_PASSWORD ConnectionResult.INVALID_UUID -> RegistrationStatus.FORBIDDEN_UUID ConnectionResult.INVALID_ENDPOINT -> RegistrationStatus.FORBIDDEN_ENDPOINT null -> RegistrationStatus.BAD_RESPONSE From 4ffe7f2b955f959a42147de9b15c8e3bb2ec94a2 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 19 Nov 2024 15:07:25 +0000 Subject: [PATCH 07/13] Register to distributor with VAPID --- .../java/im/molly/unifiedpush/UnifiedPushDistributor.kt | 4 ++-- .../app/notifications/UnifiedPushSettingsViewModel.kt | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt index 3000f8de79..761d5eac79 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt @@ -2,16 +2,16 @@ package im.molly.unifiedpush import android.content.Context import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.ui.SelectDistributorDialogsBuilder import org.unifiedpush.android.connector.ui.UnifiedPushFunctions object UnifiedPushDistributor { - @JvmStatic fun registerApp() { - UnifiedPush.registerApp(AppDependencies.application) + UnifiedPush.registerApp(AppDependencies.application, vapid = SignalStore.unifiedpush.mollySocketVapid) } @JvmStatic 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 index e4783f745e..525f2a709b 100644 --- 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 @@ -144,10 +144,6 @@ class UnifiedPushSettingsViewModel(private val application: Application) : ViewM refreshAndUpdateRegistration(pingOnRegister = true) } - fun setMollySocketVapid(vapid: String) { - SignalStore.unifiedpush.mollySocketVapid = vapid - } - class Factory(private val application: Application) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return requireNotNull(modelClass.cast(UnifiedPushSettingsViewModel(application))) From 6285d1bb74d2348be449db782dbce10c28fdeb89 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Tue, 19 Nov 2024 18:25:01 +0100 Subject: [PATCH 08/13] Fix minor bugs and improve code style --- app/src/main/AndroidManifest.xml | 14 +++- .../securesms/ApplicationContext.java | 2 +- .../NotificationsSettingsFragment.kt | 42 ++++++------ .../NotificationsSettingsViewModel.kt | 11 ++-- app/src/main/res/values/strings2.xml | 12 ++-- .../unifiedpush/UnifiedPushDistributor.kt | 5 +- .../app/notifications/MollySocketLinkData.kt | 49 -------------- .../MollySocketQrScanRepository.kt | 51 +++++++-------- .../notifications/MollySocketQrScanScreen.kt | 6 +- .../MollySocketQrScannerActivity.kt | 38 ++++------- .../MollySocketQrScannerViewModel.kt | 9 +-- .../notifications/QrImageSelectionActivity.kt | 65 ------------------- .../app/notifications/QrScanResult.kt | 21 ++---- .../UnifiedPushSettingsFragment.kt | 2 +- .../unifiedpush/jobs/UnifiedPushRefreshJob.kt | 15 +++-- .../im/molly/unifiedpush/model/MollySocket.kt | 36 ++++++++++ 16 files changed, 136 insertions(+), 242 deletions(-) delete mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketLinkData.kt delete mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrImageSelectionActivity.kt create mode 100644 app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocket.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b04c78cb58..1284ea9d3e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -107,6 +107,17 @@ + + + + @@ -1069,9 +1080,6 @@ android:windowSoftInputMode="adjustResize" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="false"/> - - - = registerForActivityResult( - UnifiedPushDefaultDistributorLinkActivity.Contract() - ) { success -> - if (success != true) { - // If there are no distributors or - // if there are multiple distributors installed, but none of them follow the last - // specifications, - // we try to fall back to the first we found. - viewModel.selectFirstDistributor() + private val linkDefaultDistributorLauncher: ActivityResultLauncher = + registerForActivityResult(UnifiedPushDefaultDistributorLinkActivity.Contract()) { success -> + if (success != true) { + // If there are no distributors or + // if there are multiple distributors installed, but none of them follow the last + // specifications, + // we try to fall back to the first we found. + viewModel.selectFirstDistributor() + } + navigateToUnifiedPushSettings() } - navigateToUnifiedPushSettings() - } - private val qrScanLauncher: ActivityResultLauncher = registerForActivityResult(MollySocketQrScannerActivity.Contract()) { data -> - if (data != null) { - viewModel.initializeMollySocket(data.type == "airgapped", data.url, data.vapid) - viewModel.setPreferredNotificationMethod(NotificationDeliveryMethod.UNIFIEDPUSH) - linkDefaultDistributorLauncher.launch() + private val qrScanLauncher: ActivityResultLauncher = + registerForActivityResult(MollySocketQrScannerActivity.Contract()) { mollySocket -> + if (mollySocket != null) { + viewModel.initializeMollySocket(mollySocket) + viewModel.setPreferredNotificationMethod(NotificationDeliveryMethod.UNIFIEDPUSH) + linkDefaultDistributorLauncher.launch() + } } - } private val layoutManager: LinearLayoutManager? get() = recyclerView?.layoutManager as? LinearLayoutManager - private var hasShownPlayServicesError = false - override fun onResume() { super.onResume() viewModel.refresh() @@ -430,11 +428,11 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__ 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) { _, _ -> + .setMessage(R.string.NotificationsSettingsFragment__to_use_unifiedpush_you_need_access_to_a_running_mollysocket) + .setPositiveButton(R.string.RegistrationActivity_i_understand) { _, _ -> qrScanLauncher.launch() } - .setNegativeButton(R.string.no, null) + .setNegativeButton(R.string.RegistrationActivity_cancel, null) .setNeutralButton(R.string.LearnMoreTextView_learn_more) { _, _ -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.mollysocket_setup_url)) } 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 dd5c5a25cc..dd49666b59 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 @@ -7,6 +7,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import im.molly.unifiedpush.UnifiedPushDistributor +import im.molly.unifiedpush.model.MollySocket import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -124,10 +125,12 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer UnifiedPushDistributor.selectFirstDistributor() } - fun initializeMollySocket(airgapped: Boolean, url: String?, vapid: String) { - SignalStore.unifiedpush.airGapped = airgapped - SignalStore.unifiedpush.mollySocketUrl = url - SignalStore.unifiedpush.mollySocketVapid = vapid + fun initializeMollySocket(mollySocket: MollySocket) { + SignalStore.unifiedpush.apply { + airGapped = mollySocket is MollySocket.AirGapped + mollySocketUrl = (mollySocket as? MollySocket.WebServer)?.url + mollySocketVapid = mollySocket.vapid + } } fun setPlayServicesErrorCode(errorCode: Int?) { diff --git a/app/src/main/res/values/strings2.xml b/app/src/main/res/values/strings2.xml index e1d4182d7a..6854842408 100644 --- a/app/src/main/res/values/strings2.xml +++ b/app/src/main/res/values/strings2.xml @@ -132,7 +132,7 @@ 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? + To use UnifiedPush, you need access to a running MollySocket server to link your Signal account. MollySocket server Not selected None available @@ -157,15 +157,14 @@ Waiting for UnifiedPush distributor response The endpoint is forbidden by the server No UnifiedPush distributor installed - The registration is no longer valid No distributor app selected Enable if your MollySocket server can\'t be reached from the internet. You\'ll need to manually add your account to the server. "MollySocket server not found. Please check the URL and try again." You\'ve reached the limit of %d linked devices. To link your MollySocket server, please remove a device first. Your UnifiedPush endpoint has changed. You must update your connection on MollySocket. Your registration on MollySocket is no longer valid. Please remove the linked device and try registering again. - Your MollySocket server is configured to refuse this account. - Your MollySocket server is configured to refuse this push server. + Your account was refused by the MollySocket server. Please check the allowed UUIDs in the server configuration. + Your push server was refused by the MollySocket server. Please check the permitted endpoints in the server configuration. Registration with your UnifiedPush distributor failed. This could be due to a network issue or a missing requirement from your distributor. This is a test notification from the MollySocket server. UnifiedPush @@ -173,8 +172,7 @@ WebSocket WebSocket Service Try scanning another image containing a MollySocket QR code. - QR code not found - The QR code was invalid. - MollySocket server not found at %s + The QR code was invalid + MollySocket server not found at \'%s\' Experienced a network error. Please try again. diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt index 761d5eac79..e96cb204ba 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt @@ -2,7 +2,6 @@ package im.molly.unifiedpush import android.content.Context import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.keyvalue.SignalStore import org.unifiedpush.android.connector.UnifiedPush import org.unifiedpush.android.connector.ui.SelectDistributorDialogsBuilder import org.unifiedpush.android.connector.ui.UnifiedPushFunctions @@ -10,8 +9,8 @@ import org.unifiedpush.android.connector.ui.UnifiedPushFunctions object UnifiedPushDistributor { @JvmStatic - fun registerApp() { - UnifiedPush.registerApp(AppDependencies.application, vapid = SignalStore.unifiedpush.mollySocketVapid) + fun registerApp(vapid: String?) { + UnifiedPush.registerApp(AppDependencies.application, vapid = vapid) } @JvmStatic diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketLinkData.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketLinkData.kt deleted file mode 100644 index 4362ffc03d..0000000000 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketLinkData.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package im.molly.unifiedpush.components.settings.app.notifications - -import java.net.URI -import java.util.Optional - -data class MollySocketLinkData( - val vapid: String, - val type: String, - val url: String? -) { - companion object { - fun parse(url: String): Optional { - val uri = URI(url) - val params: MutableMap = emptyMap().toMutableMap() - uri.rawQuery.split('&').forEach { rawParam -> - rawParam.split('=').let { - params[it.getOrElse(0) { "" }] = URI("#${it.getOrElse(1) { "" }}").fragment - } - } - val vapid = params["vapid"] - val type = params["type"] - val parsedUrl = params["url"] - if ( - uri.scheme == "mollysocket" - && uri.authority == "link" - && vapid?.length == 87 - && ( - type == "airgapped" - || (type == "webserver" && URI(parsedUrl).scheme == "https") - ) - ) { - return Optional.of( - MollySocketLinkData( - vapid = vapid, - type = type, - url = parsedUrl - ) - ) - } else { - return Optional.empty() - } - } - } -} \ No newline at end of file diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt index 7314e8fcf0..6d0ab9ea36 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt @@ -1,8 +1,3 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - package im.molly.unifiedpush.components.settings.app.notifications import android.content.Context @@ -10,14 +5,13 @@ import android.net.Uri import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import im.molly.unifiedpush.MollySocketRepository +import im.molly.unifiedpush.model.MollySocket import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import okhttp3.HttpUrl.Companion.toHttpUrl import org.signal.core.util.logging.Log import org.signal.core.util.toOptional import org.signal.qr.QrProcessor -import kotlin.jvm.optionals.getOrNull - /** * A collection of functions to help with scanning QR codes for MollySocket. @@ -26,26 +20,28 @@ object MollySocketQrScanRepository { private const val TAG = "MollySocketQrScanRepository" /** - * Given a URL, will attempt to lookup MollySocket informations, coercing it to a standard set of [QrScanResult]s. + * Resolves QR data to a MollySocket link URI, coercing it to a standard set of [QrScanResult]s. */ - fun lookupUrl(url: String): Single { - val data = MollySocketLinkData.parse(url).getOrNull() - if(data == null || (data.type == "webserver" && data.url == null)) { - return Single.just(QrScanResult.InvalidData) - } - if (data.type == "airgapped") { - return Single.just(QrScanResult.Success(data)) - } - return checkMollySocketServer(data.url ?: "").map { found -> - if (found) { - QrScanResult.Success(data) - } else { - // TODO add network check - QrScanResult.NotFound( - url = data.url ?: "" - ) + fun lookupQrLink(data: String): Single { + val uri = Uri.parse(data) + return when (val mollySocket = MollySocket.parseLink(uri)) { + is MollySocket.AirGapped -> { + Single.just(QrScanResult.Success(data)) } - }.subscribeOn(Schedulers.io()) + + is MollySocket.WebServer -> { + checkMollySocketServer(mollySocket.url).map { found -> + if (found) { + QrScanResult.Success(data) + } else { + // TODO add network check + QrScanResult.NotFound(data) + } + }.subscribeOn(Schedulers.io()) + } + + else -> Single.just(QrScanResult.InvalidData) + } } private fun checkMollySocketServer(url: String): Single { @@ -60,9 +56,6 @@ object MollySocketQrScanRepository { }.subscribeOn(Schedulers.io()) } - /** - * Given a URI pointing to an image that may contain a username QR code, this will attempt to lookup the username, coercing it to a standard set of [QrScanResult]s. - */ fun scanImageUriForQrCode(context: Context, uri: Uri): Single { val loadBitmap = Glide.with(context) .asBitmap() @@ -74,7 +67,7 @@ object MollySocketQrScanRepository { .map { QrProcessor().getScannedData(it).toOptional() } .flatMap { if (it.isPresent) { - lookupUrl(it.get()) + lookupQrLink(it.get()) } else { Single.just(QrScanResult.QrNotFound) } diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt index a9224bc6f9..718b65e80c 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt @@ -42,7 +42,7 @@ fun MollySocketQrScanScreen( onQrResultHandled: () -> Unit, onOpenCameraClicked: () -> Unit, onOpenGalleryClicked: () -> Unit, - onDataFound: (MollySocketLinkData) -> Unit, + onDataFound: (String) -> Unit, hasCameraPermission: Boolean, modifier: Modifier = Modifier ) { @@ -57,14 +57,14 @@ fun MollySocketQrScanScreen( QrScanResult.QrNotFound -> { QrScanResultDialog( - title = stringResource(R.string.MollySocketLink_qr_code_not_found), + title = stringResource(R.string.UsernameLinkSettings_qr_code_not_found), message = stringResource(R.string.MollySocketLink_try_scanning_another_image_containing_a_mollysocket_qr_code), onDismiss = onQrResultHandled ) } is QrScanResult.NotFound -> { - QrScanResultDialog(message = stringResource(R.string.MollySocketLink_mollysocket_server_not_found_at_s, qrScanResult.url), onDismiss = onQrResultHandled) + QrScanResultDialog(message = stringResource(R.string.MollySocketLink_mollysocket_server_not_found_at_s, qrScanResult.data), onDismiss = onQrResultHandled) } is QrScanResult.Success -> { diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt index 69809e9618..0edb45a007 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt @@ -1,8 +1,3 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - @file:OptIn(ExperimentalPermissionsApi::class) package im.molly.unifiedpush.components.settings.app.notifications @@ -11,6 +6,7 @@ import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import android.widget.Toast import androidx.activity.compose.setContent @@ -36,11 +32,13 @@ import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberPermissionState +import im.molly.unifiedpush.model.MollySocket import io.reactivex.rxjava3.disposables.CompositeDisposable import org.signal.core.ui.Dialogs import org.signal.core.ui.theme.SignalTheme import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.QrImageSelectionActivity import org.thoughtcrime.securesms.permissions.PermissionCompat import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.util.DynamicTheme @@ -51,12 +49,6 @@ import org.thoughtcrime.securesms.util.DynamicTheme */ class MollySocketQrScannerActivity : AppCompatActivity() { - companion object { - private const val KEY_URL = "url" - private const val KEY_TYPE = "type" - private const val KEY_VAPID = "vapid" - } - private val viewModel: MollySocketQrScannerViewModel by viewModels() private val disposables = LifecycleDisposable() @@ -107,15 +99,11 @@ class MollySocketQrScannerActivity : AppCompatActivity() { } }, onDataFound = { data -> - val intent = Intent().apply { - putExtra(KEY_VAPID, data.vapid) - putExtra(KEY_URL, data.url) - putExtra(KEY_TYPE, data.type) - } - setResult(RESULT_OK, intent) + setResult(RESULT_OK, Intent().setData(Uri.parse(data))) finish() }, onBackNavigationPressed = { + setResult(RESULT_CANCELED) finish() } ) @@ -132,18 +120,16 @@ class MollySocketQrScannerActivity : AppCompatActivity() { .execute() } - class Contract : ActivityResultContract() { + class Contract : ActivityResultContract() { override fun createIntent(context: Context, input: Unit): Intent { return Intent(context, MollySocketQrScannerActivity::class.java) } - override fun parseResult(resultCode: Int, intent: Intent?): MollySocketLinkData? { - return intent?.let { - MollySocketLinkData( - type = intent.getStringExtra(KEY_TYPE) ?: return null, - url = intent.getStringExtra(KEY_URL), - vapid = intent.getStringExtra(KEY_VAPID) ?: return null - ) + override fun parseResult(resultCode: Int, intent: Intent?): MollySocket? { + return if (resultCode == RESULT_OK) { + MollySocket.parseLink(intent?.data!!) + } else { + null } } } @@ -161,7 +147,7 @@ fun Content( onQrResultHandled: () -> Unit, onOpenCameraClicked: () -> Unit, onOpenGalleryClicked: () -> Unit, - onDataFound: (MollySocketLinkData) -> Unit, + onDataFound: (String) -> Unit, onBackNavigationPressed: () -> Unit ) { Scaffold( diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt index bcab4952c9..ddd616c9dc 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt @@ -1,8 +1,3 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - package im.molly.unifiedpush.components.settings.app.notifications import android.content.Context @@ -22,10 +17,10 @@ class MollySocketQrScannerViewModel : ViewModel() { private val disposables = CompositeDisposable() - fun onQrScanned(url: String) { + fun onQrScanned(data: String) { _state.value = state.value.copy(indeterminateProgress = true) - disposables += MollySocketQrScanRepository.lookupUrl(url) + disposables += MollySocketQrScanRepository.lookupQrLink(data) .observeOn(AndroidSchedulers.mainThread()) .subscribe { result -> _state.value = _state.value.copy( diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrImageSelectionActivity.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrImageSelectionActivity.kt deleted file mode 100644 index c1d439db7a..0000000000 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrImageSelectionActivity.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package im.molly.unifiedpush.components.settings.app.notifications - - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.WindowManager -import androidx.activity.result.contract.ActivityResultContract -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDelegate -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.mediasend.Media -import org.thoughtcrime.securesms.mediasend.v2.gallery.MediaGalleryFragment - -/** - * Select qr code from gallery instead of using camera. Used in usernames and when linking devices - */ -class QrImageSelectionActivity : AppCompatActivity(), MediaGalleryFragment.Callbacks { - - override fun attachBaseContext(newBase: Context) { - delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES - super.attachBaseContext(newBase) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN) - setContentView(R.layout.username_qr_image_selection_activity) - } - - @SuppressLint("LogTagInlined") - override fun onMediaSelected(media: Media) { - setResult(RESULT_OK, Intent().setData(media.uri)) - finish() - } - - override fun onToolbarNavigationClicked() { - setResult(RESULT_CANCELED) - finish() - } - - override fun isCameraEnabled() = false - override fun isMultiselectEnabled() = false - - class Contract : ActivityResultContract() { - override fun createIntent(context: Context, input: Unit): Intent { - return Intent(context, QrImageSelectionActivity::class.java) - } - - override fun parseResult(resultCode: Int, intent: Intent?): Uri? { - return if (resultCode == RESULT_OK) { - intent?.data - } else { - null - } - } - } -} diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt index 93c96ec9a7..a301358208 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt @@ -1,22 +1,13 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - package im.molly.unifiedpush.components.settings.app.notifications - -/** - * Result of taking data from the QR scanner and trying to resolve it to a recipient. - */ sealed class QrScanResult { - class Success(val data: MollySocketLinkData) : QrScanResult() + class Success(val data: String) : QrScanResult() - class NotFound(val url: String) : QrScanResult() + class NotFound(val data: String) : QrScanResult() - object InvalidData : QrScanResult() + data object InvalidData : QrScanResult() - object NetworkError : QrScanResult() + data object NetworkError : QrScanResult() - object QrNotFound : QrScanResult() -} \ No newline at end of file + data object QrNotFound : QrScanResult() +} 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 index 02a63dec5c..54aa843f03 100644 --- 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 @@ -202,7 +202,7 @@ class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDel RegistrationStatus.SERVER_ERROR -> R.string.UnifiedPushSettingsFragment__status_summary_bad_response RegistrationStatus.REGISTERED -> android.R.string.ok - RegistrationStatus.FORBIDDEN_PASSWORD -> R.string.UnifiedPushSettingsFragment__status_summary_bad_password + RegistrationStatus.FORBIDDEN_PASSWORD -> R.string.UnifiedPushSettingsFragment__status_summary_pending RegistrationStatus.FORBIDDEN_UUID -> R.string.UnifiedPushSettingsFragment__status_summary_forbidden_uuid RegistrationStatus.FORBIDDEN_ENDPOINT -> R.string.UnifiedPushSettingsFragment__status_summary_forbidden_endpoint } diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt index f5388497bb..eaa68b5161 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt @@ -1,22 +1,22 @@ package im.molly.unifiedpush.jobs +import im.molly.unifiedpush.MollySocketRepository +import im.molly.unifiedpush.MollySocketRepository.isLinked import im.molly.unifiedpush.UnifiedPushDistributor import im.molly.unifiedpush.UnifiedPushNotificationBuilder +import im.molly.unifiedpush.model.RegistrationStatus +import im.molly.unifiedpush.model.toRegistrationStatus +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.greenrobot.eventbus.EventBus import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.events.PushServiceEvent import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.JsonJobData import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.jobs.BaseJob import org.thoughtcrime.securesms.jobs.FcmRefreshJob import org.thoughtcrime.securesms.keyvalue.SignalStore -import im.molly.unifiedpush.MollySocketRepository -import im.molly.unifiedpush.MollySocketRepository.isLinked -import im.molly.unifiedpush.model.RegistrationStatus -import im.molly.unifiedpush.model.toRegistrationStatus -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.thoughtcrime.securesms.jobmanager.JsonJobData import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException import java.io.IOException @@ -98,6 +98,7 @@ class UnifiedPushRefreshJob private constructor( val airGapped = SignalStore.unifiedpush.airGapped val mollySocketUrl = SignalStore.unifiedpush.mollySocketUrl?.toHttpUrlOrNull() val lastReceivedTime = SignalStore.unifiedpush.lastReceivedTime + val vapid = SignalStore.unifiedpush.mollySocketVapid Log.d(TAG, "Last notification received at: $lastReceivedTime") @@ -108,7 +109,7 @@ class UnifiedPushRefreshJob private constructor( } } - UnifiedPushDistributor.registerApp() + UnifiedPushDistributor.registerApp(vapid) if (!UnifiedPushDistributor.checkIfActive() || endpoint == null) { Log.e(TAG, "Distributor is not active or endpoint is missing.") diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocket.kt b/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocket.kt new file mode 100644 index 0000000000..8dea76ef67 --- /dev/null +++ b/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocket.kt @@ -0,0 +1,36 @@ +package im.molly.unifiedpush.model + +import android.net.Uri + +sealed class MollySocket(val vapid: String) { + + class AirGapped(vapid: String) : MollySocket(vapid) + class WebServer(vapid: String, val url: String) : MollySocket(vapid) + + companion object { + fun parseLink(uri: Uri): MollySocket? { + if (!uri.isHierarchical || uri.scheme != "mollysocket" || uri.authority != "link") { + return null + } + val vapid = uri.getQueryParameter("vapid") + if (vapid?.length != 87) { + return null + } + val type = uri.getQueryParameter("type") + return when (type) { + "airgapped" -> { + AirGapped(vapid) + } + + "webserver" -> { + val url = uri.getQueryParameter("url") ?: return null + val parsedUrl = Uri.parse(url) + if (parsedUrl.scheme != "https") return null + WebServer(vapid = vapid, url = url) + } + + else -> null + } + } + } +} From c12ecdb0de2621732d4870fa6442e9ae9fcad3da Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Tue, 19 Nov 2024 19:48:43 +0100 Subject: [PATCH 09/13] Move unifiedpush files into main source set --- app/build.gradle.kts | 4 ---- .../unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt | 5 ----- .../java/im/molly/unifiedpush/UnifiedPushDistributor.kt | 0 .../im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt | 0 .../app/notifications/MollySocketQrScanRepository.kt | 0 .../settings/app/notifications/MollySocketQrScanScreen.kt | 0 .../app/notifications/MollySocketQrScannerActivity.kt | 0 .../app/notifications/MollySocketQrScannerViewModel.kt | 0 .../components/settings/app/notifications/QrScanResult.kt | 0 .../app/notifications/UnifiedPushSettingsFragment.kt | 0 .../settings/app/notifications/UnifiedPushSettingsState.kt | 0 .../app/notifications/UnifiedPushSettingsViewModel.kt | 2 +- .../java/im/molly/unifiedpush/model/ConnectionRequest.kt | 0 .../java/im/molly/unifiedpush/model/MollySocket.kt | 0 .../java/im/molly/unifiedpush/model/MollySocketDevice.kt | 0 .../im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt | 2 +- .../java/org/thoughtcrime/securesms/ApplicationContext.java | 2 +- .../org/thoughtcrime/securesms/jobs/JobManagerFactories.java | 2 -- .../thoughtcrime/securesms}/jobs/UnifiedPushRefreshJob.kt | 4 +--- .../org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt | 0 20 files changed, 4 insertions(+), 17 deletions(-) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt (95%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/UnifiedPushDistributor.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt (98%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/model/ConnectionRequest.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/model/MollySocket.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/model/MollySocketDevice.kt (100%) rename app/src/{unifiedpush => main}/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt (98%) rename app/src/{unifiedpush/java/im/molly/unifiedpush => main/java/org/thoughtcrime/securesms}/jobs/UnifiedPushRefreshJob.kt (97%) rename app/src/{unifiedpush => main}/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt (100%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db664f6b82..9ee2999656 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,10 +122,6 @@ android { getByName("androidTest") { java.srcDir("$projectDir/src/testShared") } - - getByName("main") { - java.srcDir("$projectDir/src/unifiedpush/java") - } } compileOptions { diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt b/app/src/main/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt similarity index 95% rename from app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt rename to app/src/main/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt index a27a20fc3b..0d75931b93 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt +++ b/app/src/main/java/im/molly/unifiedpush/UnifiedPushDefaultDistributorLinkActivity.kt @@ -1,8 +1,3 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - package im.molly.unifiedpush import android.content.Context diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt b/app/src/main/java/im/molly/unifiedpush/UnifiedPushDistributor.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushDistributor.kt rename to app/src/main/java/im/molly/unifiedpush/UnifiedPushDistributor.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt b/app/src/main/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt rename to app/src/main/java/im/molly/unifiedpush/UnifiedPushNotificationBuilder.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt rename to app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt rename to app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanScreen.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt rename to app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerActivity.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt rename to app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScannerViewModel.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt rename to app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/QrScanResult.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt rename to app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt rename to app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsState.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt similarity index 98% rename from app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt rename to app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt index 525f2a709b..50ae4ac33e 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt +++ b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt @@ -8,13 +8,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import im.molly.unifiedpush.MollySocketRepository -import im.molly.unifiedpush.jobs.UnifiedPushRefreshJob import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.UnifiedPushRefreshJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor import org.thoughtcrime.securesms.util.livedata.Store diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/model/ConnectionRequest.kt b/app/src/main/java/im/molly/unifiedpush/model/ConnectionRequest.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/model/ConnectionRequest.kt rename to app/src/main/java/im/molly/unifiedpush/model/ConnectionRequest.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocket.kt b/app/src/main/java/im/molly/unifiedpush/model/MollySocket.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocket.kt rename to app/src/main/java/im/molly/unifiedpush/model/MollySocket.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt b/app/src/main/java/im/molly/unifiedpush/model/MollySocketDevice.kt similarity index 100% rename from app/src/unifiedpush/java/im/molly/unifiedpush/model/MollySocketDevice.kt rename to app/src/main/java/im/molly/unifiedpush/model/MollySocketDevice.kt diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt b/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt similarity index 98% rename from app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt rename to app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt index 7870e4179b..ce6a930f12 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt +++ b/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt @@ -3,13 +3,13 @@ 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.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.jobs.UnifiedPushRefreshJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.util.TextSecurePreferences diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index b124c09e6d..831a98fa35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -80,6 +80,7 @@ import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob; import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob; +import org.thoughtcrime.securesms.jobs.UnifiedPushRefreshJob; import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration; import org.thoughtcrime.securesms.keyvalue.SettingsValues.NotificationDeliveryMethod; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -129,7 +130,6 @@ import java.util.concurrent.TimeUnit; import im.molly.unifiedpush.UnifiedPushDistributor; -import im.molly.unifiedpush.jobs.UnifiedPushRefreshJob; import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException; import io.reactivex.rxjava3.exceptions.UndeliverableException; import io.reactivex.rxjava3.plugins.RxJavaPlugins; 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 d4d2e73570..c5abf95c94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -97,8 +97,6 @@ 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) { diff --git a/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/UnifiedPushRefreshJob.kt similarity index 97% rename from app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt rename to app/src/main/java/org/thoughtcrime/securesms/jobs/UnifiedPushRefreshJob.kt index eaa68b5161..8b06ba3df4 100644 --- a/app/src/unifiedpush/java/im/molly/unifiedpush/jobs/UnifiedPushRefreshJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/UnifiedPushRefreshJob.kt @@ -1,4 +1,4 @@ -package im.molly.unifiedpush.jobs +package org.thoughtcrime.securesms.jobs import im.molly.unifiedpush.MollySocketRepository import im.molly.unifiedpush.MollySocketRepository.isLinked @@ -14,8 +14,6 @@ import org.thoughtcrime.securesms.events.PushServiceEvent import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.JsonJobData import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint -import org.thoughtcrime.securesms.jobs.BaseJob -import org.thoughtcrime.securesms.jobs.FcmRefreshJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException diff --git a/app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt similarity index 100% rename from app/src/unifiedpush/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt rename to app/src/main/java/org/thoughtcrime/securesms/keyvalue/UnifiedPushValues.kt From b3ae2aad1fe158901618616a1b13ff448469d126 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 21 Nov 2024 10:10:15 +0000 Subject: [PATCH 10/13] Update push status when receiving test notification --- .../java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt b/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt index ce6a930f12..a843eab6f4 100644 --- a/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt +++ b/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt @@ -91,6 +91,7 @@ class UnifiedPushReceiver : MessagingReceiver() { message.contains("\"test\":true") -> { Log.d(TAG, "Test message received.") UnifiedPushNotificationBuilder(context).setNotificationTest() + AppDependencies.jobManager.add(UnifiedPushRefreshJob()) } else -> { From de2b524d0a8c429367689789ae2fced8bb2c1cad Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 21 Nov 2024 10:13:54 +0000 Subject: [PATCH 11/13] Do not show notification for changed endpoint if not updated --- .../im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt b/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt index a843eab6f4..7a7532d762 100644 --- a/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt +++ b/app/src/main/java/im/molly/unifiedpush/receiver/UnifiedPushReceiver.kt @@ -34,8 +34,7 @@ class UnifiedPushReceiver : MessagingReceiver() { override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) { Log.i(TAG, "onNewEndpoint($instance)") if (!appLocked) { - refreshEndpoint(endpoint.url) - if (SignalStore.unifiedpush.airGapped) { + if (refreshEndpoint(endpoint.url) && SignalStore.unifiedpush.airGapped) { updateLastReceivedTime(0) UnifiedPushNotificationBuilder(context).setNotificationEndpointChangedAirGapped() } @@ -109,6 +108,11 @@ class UnifiedPushReceiver : MessagingReceiver() { SignalStore.unifiedpush.lastReceivedTime = timestamp } + /** + * Update UnifiedPush endpoint + * + * @return `true` if the endpoint has been updated, `false` if the endpoint is the same + */ private fun refreshEndpoint(endpoint: String?): Boolean { val stored = SignalStore.unifiedpush.endpoint return if (endpoint != stored) { From 71ec1d646f30477628a58154ccefddc6101725cb Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 21 Nov 2024 15:35:31 +0000 Subject: [PATCH 12/13] Fix mollysocket qr code scan error message --- .../settings/app/notifications/MollySocketQrScanRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt index 6d0ab9ea36..02863f0c18 100644 --- a/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt +++ b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/MollySocketQrScanRepository.kt @@ -35,7 +35,7 @@ object MollySocketQrScanRepository { QrScanResult.Success(data) } else { // TODO add network check - QrScanResult.NotFound(data) + QrScanResult.NotFound(mollySocket.url) } }.subscribeOn(Schedulers.io()) } From d5c05fa94603addbaaccc9b63ca19a81520ba1c0 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 21 Nov 2024 11:01:28 +0000 Subject: [PATCH 13/13] Add QR code scanner to UnifiedPush settings And remove options to manually edit MollySocket setup --- .../UnifiedPushSettingsFragment.kt | 105 +++++------------- .../UnifiedPushSettingsViewModel.kt | 9 ++ app/src/main/res/values/strings2.xml | 9 +- 3 files changed, 43 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt index 54aa843f03..fe03e736e9 100644 --- a/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt +++ b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsFragment.kt @@ -1,14 +1,10 @@ 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 android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.launch import androidx.annotation.StringRes import androidx.lifecycle.ViewModelProvider -import com.google.android.material.dialog.MaterialAlertDialogBuilder import im.molly.unifiedpush.model.RegistrationStatus import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -28,6 +24,14 @@ class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDel private lateinit var viewModel: UnifiedPushSettingsViewModel + private val qrScanLauncher: ActivityResultLauncher = + registerForActivityResult(MollySocketQrScannerActivity.Contract()) { mollySocket -> + if (mollySocket != null) { + viewModel.initializeMollySocket(mollySocket) + viewModel.refresh() + } + } + override fun bindAdapter(adapter: MappingAdapter) { val factory = UnifiedPushSettingsViewModel.Factory(requireActivity().application) @@ -54,6 +58,19 @@ class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDel summary = DSLSettingsText.from(getStatusSummary(state)), ) + textPref( + title = DSLSettingsText.from(R.string.UnifiedPushSettingsFragment__mollysocket_server_title), + summary = DSLSettingsText.from( + if (state.airGapped) { + getString(R.string.UnifiedPushSettingsFragment__mollysocket_server_sumarry_air_gapped) + } else { + state.mollySocketUrl ?: "Error" + } + ) + ) + + dividerPref() + if (state.distributors.isEmpty()) { textPref( title = DSLSettingsText.from(R.string.UnifiedPushSettingsFragment__distributor_app), @@ -70,17 +87,6 @@ class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDel ) } - 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) { val parameters = getServerParameters(state) ?: "" @@ -105,13 +111,6 @@ class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDel }, ) - 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) }, - ) - clickPref( title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__test_configuration)), summary = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__tap_to_request_a_test_notification_from_mollysocket)), @@ -121,6 +120,13 @@ class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDel }, ) } + clickPref( + title = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__change_mollysocket_configuration_title)), + summary = DSLSettingsText.from(getString(R.string.UnifiedPushSettingsFragment__change_mollysocket_configuration_summary)), + onClick = { + qrScanLauncher.launch() + }, + ) } } @@ -131,49 +137,6 @@ class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDel return "connection add $aci ${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 -> - val isValid = viewModel.setMollySocketUrl(input.text.toString()) - if (!isValid && input.text.isNotEmpty()) { - Toast.makeText(requireContext(), R.string.UnifiedPushSettingsFragment__invalid_server_url, Toast.LENGTH_LONG).show() - } - } - 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 - } - @StringRes private fun getStatusSummary(state: UnifiedPushSettingsState): Int { return when { @@ -208,12 +171,4 @@ class UnifiedPushSettingsFragment : DSLSettingsFragment(R.string.NotificationDel } } } - - private fun getMollySocketUrlIcon(state: UnifiedPushSettingsState): DSLSettingsIcon? { - return when (state.serverUnreachable) { - true -> DSLSettingsIcon.from(R.drawable.ic_alert) - false -> DSLSettingsIcon.from(R.drawable.ic_check_20) - else -> null - } - } } diff --git a/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt index 50ae4ac33e..153f996fee 100644 --- a/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt +++ b/app/src/main/java/im/molly/unifiedpush/components/settings/app/notifications/UnifiedPushSettingsViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import im.molly.unifiedpush.MollySocketRepository +import im.molly.unifiedpush.model.MollySocket import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.signal.core.util.ThreadUtil @@ -33,6 +34,14 @@ class UnifiedPushSettingsViewModel(private val application: Application) : ViewM store.update { getState() } } + fun initializeMollySocket(mollySocket: MollySocket) { + SignalStore.unifiedpush.apply { + airGapped = mollySocket is MollySocket.AirGapped + mollySocketUrl = (mollySocket as? MollySocket.WebServer)?.url + mollySocketVapid = mollySocket.vapid + } + } + private fun refreshAndUpdateRegistration(pingOnRegister: Boolean = false) { refresh() AppDependencies.jobManager.add(UnifiedPushRefreshJob(pingOnRegister)) diff --git a/app/src/main/res/values/strings2.xml b/app/src/main/res/values/strings2.xml index 6854842408..a5e032ac2e 100644 --- a/app/src/main/res/values/strings2.xml +++ b/app/src/main/res/values/strings2.xml @@ -139,15 +139,11 @@ Distributor app Status Tap to copy to clipboard - Air gapped Server parameters Account ID - Server URL - Please provide the MollySocket server URL Test configuration Tap to request a test notification from MollySocket A test notification should appear in a few moments… - Invalid server URL Missing MollySocket URL Pending Waiting for confirmation from the MollySocket server @@ -158,7 +154,6 @@ The endpoint is forbidden by the server No UnifiedPush distributor installed No distributor app selected - Enable if your MollySocket server can\'t be reached from the internet. You\'ll need to manually add your account to the server. "MollySocket server not found. Please check the URL and try again." You\'ve reached the limit of %d linked devices. To link your MollySocket server, please remove a device first. Your UnifiedPush endpoint has changed. You must update your connection on MollySocket. @@ -175,4 +170,8 @@ The QR code was invalid MollySocket server not found at \'%s\' Experienced a network error. Please try again. + Scan MollySocket QR Code + Tap to scan and select a new MollySocket server + MollySocket server + Air gapped