feat: add link device implementation and fixes for updating config syncs and forcing config syncs

This commit is contained in:
jubb 2021-03-03 11:33:35 +11:00
parent 3a09d23337
commit ef3e172379
9 changed files with 219 additions and 119 deletions

View File

@ -26,29 +26,32 @@ import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import org.session.libsession.messaging.threads.recipients.RecipientModifiedListener
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfilePictureModifiedEvent
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.Util
import org.session.libsignal.service.loki.utilities.mentions.MentionsManager
import org.session.libsignal.service.loki.utilities.toHexString
import org.session.libsignal.utilities.ThreadUtils
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.ConversationActivity
import org.session.libsession.utilities.GroupUtil
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.loki.dialogs.*
import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocolV2
import org.thoughtcrime.securesms.loki.utilities.*
import org.thoughtcrime.securesms.loki.views.ConversationView
import org.thoughtcrime.securesms.loki.views.NewConversationButtonSetViewDelegate
import org.thoughtcrime.securesms.loki.views.SeedReminderViewDelegate
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.Util
import org.session.libsignal.service.loki.utilities.mentions.MentionsManager
import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.service.loki.utilities.toHexString
import org.thoughtcrime.securesms.loki.dialogs.*
import org.thoughtcrime.securesms.loki.protocol.ClosedGroupsProtocolV2
import java.io.IOException
class HomeActivity : PassphraseRequiredActionBarActivity,
class HomeActivity : PassphraseRequiredActionBarActivity(),
ConversationClickListener,
SeedReminderViewDelegate,
NewConversationButtonSetViewDelegate {
@ -62,8 +65,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity,
}
// region Lifecycle
constructor() : super()
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
// Double check that the long poller is up
@ -152,17 +153,19 @@ class HomeActivity : PassphraseRequiredActionBarActivity,
}
this.broadcastReceiver = broadcastReceiver
LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged"))
lifecycleScope.launchWhenResumed {
lifecycleScope.launch {
// update things based on TextSecurePrefs (profile info etc)
TextSecurePreferences.events.filter { it == TextSecurePreferences.PROFILE_NAME_PREF }.collect {
updateProfileButton()
}
}
EventBus.getDefault().register(this@HomeActivity)
}
override fun onResume() {
super.onResume()
if (TextSecurePreferences.getLocalNumber(this) == null) { return; } // This can be the case after a secondary device is auto-cleared
if (TextSecurePreferences.getLocalNumber(this) == null) {
return; } // This can be the case after a secondary device is auto-cleared
profileButton.recycle() // clear cached image before update tje profilePictureView
profileButton.update()
val hasViewedSeed = TextSecurePreferences.getHasViewedSeed(this)
@ -174,13 +177,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity,
}
private fun showKeyPairMigrationSheetIfNeeded() {
if (KeyPairUtilities.hasV2KeyPair(this)) { return }
if (KeyPairUtilities.hasV2KeyPair(this)) {
return
}
val keyPairMigrationSheet = KeyPairMigrationBottomSheet()
keyPairMigrationSheet.show(supportFragmentManager, keyPairMigrationSheet.tag)
}
private fun showKeyPairMigrationSuccessSheetIfNeeded() {
if (!KeyPairUtilities.hasV2KeyPair(this) || !TextSecurePreferences.getIsMigratingKeyPair(this)) { return }
if (!KeyPairUtilities.hasV2KeyPair(this) || !TextSecurePreferences.getIsMigratingKeyPair(this)) {
return
}
val keyPairMigrationSuccessSheet = KeyPairMigrationSuccessBottomSheet()
keyPairMigrationSuccessSheet.show(supportFragmentManager, keyPairMigrationSuccessSheet.tag)
TextSecurePreferences.setIsMigratingKeyPair(this, false)
@ -199,6 +206,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity,
LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver)
}
super.onDestroy()
EventBus.getDefault().unregister(this)
}
// endregion
@ -208,6 +216,13 @@ class HomeActivity : PassphraseRequiredActionBarActivity,
emptyStateContainer.visibility = if (threadCount == 0) View.VISIBLE else View.GONE
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onUpdateProfileEvent(event: ProfilePictureModifiedEvent) {
if (event.recipient.isLocalNumber) {
updateProfileButton()
}
}
private fun updateProfileButton() {
profileButton.publicKey = publicKey
profileButton.displayName = TextSecurePreferences.getProfileName(this)
@ -307,7 +322,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity,
}
val dialog = AlertDialog.Builder(this)
dialog.setMessage(dialogMessage)
dialog.setPositiveButton(R.string.yes) { _, _ -> lifecycleScope.launch(Dispatchers.Main) {
dialog.setPositiveButton(R.string.yes) { _, _ ->
lifecycleScope.launch(Dispatchers.Main) {
val context = this@HomeActivity as Context
// Send a leave group message if this is an active closed group
@ -352,7 +368,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity,
// Notify the user
val toastMessage = if (recipient.isGroupRecipient) R.string.MessageRecord_left_group else R.string.activity_home_conversation_deleted_message
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
}}
}
}
dialog.setNegativeButton(R.string.no) { _, _ ->
// Do nothing
}

View File

@ -1,29 +1,48 @@
package org.thoughtcrime.securesms.loki.activities
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter
import android.text.InputType
import android.view.*
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_create_private_chat.*
import kotlinx.android.synthetic.main.activity_create_private_chat.tabLayout
import kotlinx.android.synthetic.main.activity_create_private_chat.viewPager
import kotlinx.android.synthetic.main.activity_link_device.*
import kotlinx.android.synthetic.main.activity_settings.*
import kotlinx.android.synthetic.main.activity_settings.loader
import kotlinx.android.synthetic.main.fragment_recovery_phrase.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.libsignal.util.KeyHelper
import org.session.libsignal.service.loki.crypto.MnemonicCodec
import org.session.libsignal.service.loki.utilities.hexEncodedPublicKey
import org.session.libsignal.utilities.Hex
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.loki.fragments.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.loki.fragments.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.loki.utilities.KeyPairUtilities
import org.thoughtcrime.securesms.loki.utilities.MnemonicUtilities
import org.thoughtcrime.securesms.loki.utilities.push
import org.thoughtcrime.securesms.loki.utilities.setUpActionBarSessionLogo
class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
private val adapter = LinkDeviceActivityAdapter(this)
private var restoreJob: Job? = null
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
@ -59,8 +78,45 @@ class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDel
}
private fun continueWithSeed(seed: ByteArray) {
restoreJob?.cancel()
restoreJob = lifecycleScope.launch {
// RestoreActivity handles seed this way
val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID)
TextSecurePreferences.setLocalNumber(this@LinkDeviceActivity, userHexEncodedPublicKey)
TextSecurePreferences.setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis())
TextSecurePreferences.setHasViewedSeed(this@LinkDeviceActivity, true)
loader.isVisible = true
// TODO: Implement
val snackBar = Snackbar.make(containerLayout, R.string.activity_link_device_skip_prompt,Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.registration_activity__skip) { register() }
val skipJob = launch {
delay(30_000L)
snackBar.show()
// show a dialog or something saying do you want to skip this bit?
}
// start polling and wait for updated message
ApplicationContext.getInstance(this@LinkDeviceActivity).startPollingIfNeeded()
TextSecurePreferences.events.filter { it == TextSecurePreferences.CONFIGURATION_SYNCED }.collect {
// handle we've synced
snackBar.dismiss()
skipJob.cancel()
register()
}
loader.isVisible = false
}
}
private fun register() {
restoreJob?.cancel()
val intent = Intent(this@LinkDeviceActivity, PNModeActivity::class.java)
push(intent)
}
// endregion
}
@ -86,7 +142,7 @@ private class LinkDeviceActivityAdapter(private val activity: LinkDeviceActivity
}
}
override fun getPageTitle(index: Int): CharSequence? {
override fun getPageTitle(index: Int): CharSequence {
return when (index) {
0 -> "Recovery Phrase"
1 -> "Scan QR Code"

View File

@ -27,29 +27,29 @@ import nl.komponents.kovenant.deferred
import nl.komponents.kovenant.functional.bind
import nl.komponents.kovenant.task
import nl.komponents.kovenant.ui.alwaysUi
import org.session.libsession.messaging.avatars.AvatarHelper
import org.session.libsession.messaging.threads.Address
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.preferences.ProfileKeyUtil
import org.session.libsignal.service.api.util.StreamDetails
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection
import org.session.libsession.utilities.preferences.ProfileKeyUtil
import org.session.libsession.messaging.threads.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.dialogs.ChangeUiModeDialog
import org.thoughtcrime.securesms.loki.dialogs.ClearAllDataDialog
import org.thoughtcrime.securesms.loki.dialogs.SeedDialog
import org.thoughtcrime.securesms.loki.protocol.MultiDeviceProtocol
import org.thoughtcrime.securesms.loki.utilities.UiModeUtilities
import org.thoughtcrime.securesms.loki.utilities.push
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.permissions.Permissions
import org.session.libsession.messaging.avatars.AvatarHelper
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints
import org.thoughtcrime.securesms.util.BitmapDecodingException
import org.thoughtcrime.securesms.util.BitmapUtil
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.service.api.util.StreamDetails
import org.session.libsignal.service.loki.api.fileserver.FileServerAPI
import org.thoughtcrime.securesms.loki.protocol.MultiDeviceProtocol
import java.io.ByteArrayInputStream
import java.io.File
import java.security.SecureRandom
@ -207,6 +207,12 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
// updating the profile name or picture
if (profilePicture != null || displayName != null) {
task {
if (isUpdatingProfilePicture && profilePicture != null) {
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
TextSecurePreferences.setProfileAvatarId(this, SecureRandom().nextInt())
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
ApplicationContext.getInstance(this).updateOpenGroupProfilePicturesIfNeeded()
}
MultiDeviceProtocol.forceSyncConfigurationNowIfNeeded(this@SettingsActivity)
}
} else {
@ -216,15 +222,11 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
if (displayName != null) {
btnGroupNameDisplay.text = displayName
}
displayNameToBeUploaded = null
if (isUpdatingProfilePicture && profilePicture != null) {
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
TextSecurePreferences.setProfileAvatarId(this, SecureRandom().nextInt())
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
ApplicationContext.getInstance(this).updateOpenGroupProfilePicturesIfNeeded()
profilePictureView.recycle() // clear cached image before update tje profilePictureView
profilePictureView.update()
}
displayNameToBeUploaded = null
profilePictureToBeUploaded = null
loader.isVisible = false
}

View File

@ -7,6 +7,7 @@ import org.session.libsession.messaging.messages.control.ConfigurationMessage
import org.session.libsession.messaging.threads.Address
import org.session.libsession.messaging.threads.recipients.Recipient
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.preferences.ProfileKeyUtil
import org.session.libsignal.libsignal.util.guava.Optional
import org.session.libsignal.service.api.push.SignalServiceAddress
import org.session.libsignal.service.internal.push.SignalServiceProtos
@ -18,12 +19,10 @@ import org.session.libsignal.utilities.logging.Log
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.loki.utilities.ContactUtilities
import org.thoughtcrime.securesms.loki.utilities.OpenGroupUtilities
import org.thoughtcrime.securesms.sskenvironment.ProfileManager
import java.security.SecureRandom
import java.util.*
object MultiDeviceProtocol {
@ -82,12 +81,18 @@ object MultiDeviceProtocol {
// TODO: remove this after we migrate to new message receiving pipeline
@JvmStatic
fun handleConfigurationMessage(context: Context, content: SignalServiceProtos.Content, senderPublicKey: String, timestamp: Long) {
// if (TextSecurePreferences.getConfigurationMessageSynced(context)) return
if (TextSecurePreferences.getConfigurationMessageSynced(context) && !TextSecurePreferences.shouldUpdateProfile(context, timestamp)) return
val configurationMessage = ConfigurationMessage.fromProto(content) ?: return
val userPublicKey = TextSecurePreferences.getLocalNumber(context) ?: return
if (senderPublicKey != userPublicKey) return
val storage = MessagingConfiguration.shared.storage
val allClosedGroupPublicKeys = storage.getAllClosedGroupPublicKeys()
val threadDatabase = DatabaseFactory.getThreadDatabase(context)
val recipientDatabase = DatabaseFactory.getRecipientDatabase(context)
val ourRecipient = Recipient.from(context, Address.fromSerialized(userPublicKey),false)
for (closedGroup in configurationMessage.closedGroups) {
if (allClosedGroupPublicKeys.contains(closedGroup.publicKey)) continue
@ -109,18 +114,20 @@ object MultiDeviceProtocol {
if (allOpenGroups.contains(openGroup)) continue
OpenGroupUtilities.addGroup(context, openGroup, 1)
}
if (configurationMessage.profileKey.isNotEmpty()) {
val profileKey = Base64.encodeBytes(configurationMessage.profileKey)
TextSecurePreferences.setProfileKey(context, profileKey)
}
if (!configurationMessage.profilePicture.isNullOrEmpty()) {
TextSecurePreferences.setProfilePictureURL(context, configurationMessage.profilePicture)
}
if (configurationMessage.displayName.isNotEmpty()) {
TextSecurePreferences.setProfileName(context, configurationMessage.displayName)
recipientDatabase.setProfileName(ourRecipient, configurationMessage.displayName)
}
if (configurationMessage.profileKey.isNotEmpty()) {
val profileKey = Base64.encodeBytes(configurationMessage.profileKey)
ProfileKeyUtil.setEncodedProfileKey(context, profileKey)
recipientDatabase.setProfileKey(ourRecipient, configurationMessage.profileKey)
if (!configurationMessage.profilePicture.isNullOrEmpty()) {
TextSecurePreferences.setProfilePictureURL(context, configurationMessage.profilePicture)
TextSecurePreferences.setProfileAvatarId(context, SecureRandom().nextInt())
ApplicationContext.getInstance(context).jobManager.add(RetrieveProfileAvatarJob(ourRecipient, configurationMessage.profilePicture))
}
}
val threadDatabase = DatabaseFactory.getThreadDatabase(context)
val recipientDatabase = DatabaseFactory.getRecipientDatabase(context)
for (contact in configurationMessage.contacts) {
val address = Address.fromSerialized(contact.publicKey)
val recipient = Recipient.from(context, address, true)
@ -142,5 +149,6 @@ object MultiDeviceProtocol {
}
// TODO: handle new configuration message fields or handle in new pipeline
TextSecurePreferences.setConfigurationMessageSynced(context, true)
TextSecurePreferences.setLastProfileUpdateTime(context, timestamp)
}
}

View File

@ -12,12 +12,12 @@ import kotlinx.android.synthetic.main.view_profile_picture.view.*
import network.loki.messenger.R
import org.session.libsession.messaging.avatars.ProfileContactPhoto
import org.session.libsession.messaging.threads.Address
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.utilities.AvatarPlaceholderGenerator
import org.thoughtcrime.securesms.mms.GlideRequests
import org.session.libsession.messaging.threads.recipients.Recipient
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.service.loki.utilities.mentions.MentionsManager
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.loki.utilities.AvatarPlaceholderGenerator
import org.thoughtcrime.securesms.mms.GlideRequests
// TODO: Look into a better way of handling different sizes. Maybe an enum (with associated values) encapsulating the different modes?
@ -151,7 +151,7 @@ class ProfilePictureView : RelativeLayout {
if (signalProfilePicture != null && (signalProfilePicture as? ProfileContactPhoto)?.avatarObject != "0"
&& (signalProfilePicture as? ProfileContactPhoto)?.avatarObject != "") {
glide.clear(imageView)
glide.load(signalProfilePicture).diskCacheStrategy(DiskCacheStrategy.ALL).circleCrop().into(imageView)
glide.load(signalProfilePicture).diskCacheStrategy(DiskCacheStrategy.AUTOMATIC).circleCrop().into(imageView)
imagesCached.add(publicKey)
} else {
val sizeInPX = resources.getDimensionPixelSize(sizeResId)

View File

@ -2,6 +2,7 @@
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/containerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">

View File

@ -1881,5 +1881,7 @@
<string name="activity_backup_restore_select_file">Select a file</string>
<string name="activity_backup_restore_explanation_1">Select a backup file and enter the passphrase it was created with.</string>
<string name="activity_backup_restore_passphrase">30-digit passphrase</string>
<!-- LinkDeviceActivity -->
<string name="activity_link_device_skip_prompt">This is taking a while, would you like to skip?</string>
</resources>

View File

@ -2,14 +2,17 @@ package org.session.libsession.messaging.sending_receiving.notifications
import android.annotation.SuppressLint
import nl.komponents.kovenant.functional.map
import okhttp3.*
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.MessagingConfiguration
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.logging.Log
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.service.loki.api.onionrequests.OnionRequestAPI
import org.session.libsignal.service.loki.utilities.retryIfNeeded
import org.session.libsignal.utilities.JsonUtil
import org.session.libsignal.utilities.logging.Log
@SuppressLint("StaticFieldLeak")
object PushNotificationAPI {
val context = MessagingConfiguration.shared.context
val server = "https://live.apns.getsession.org"

View File

@ -107,7 +107,8 @@ object TextSecurePreferences {
// region Multi Device
private const val LAST_CONFIGURATION_SYNC_TIME = "pref_last_configuration_sync_time"
private const val CONFIGURATION_SYNCED = "pref_configuration_synced"
const val CONFIGURATION_SYNCED = "pref_configuration_synced"
private const val LAST_PROFILE_UPDATE_TIME = "pref_last_profile_update_time"
@JvmStatic
fun getLastConfigurationSyncTime(context: Context): Long {
@ -127,6 +128,7 @@ object TextSecurePreferences {
@JvmStatic
fun setConfigurationMessageSynced(context: Context, value: Boolean) {
setBooleanPreference(context, CONFIGURATION_SYNCED, value)
_events.tryEmit(CONFIGURATION_SYNCED)
}
@JvmStatic
@ -753,5 +755,14 @@ object TextSecurePreferences {
fun setIsMigratingKeyPair(context: Context?, newValue: Boolean) {
setBooleanPreference(context!!, "is_migrating_key_pair", newValue)
}
@JvmStatic
fun setLastProfileUpdateTime(context: Context, profileUpdateTime: Long) {
setLongPreference(context, LAST_PROFILE_UPDATE_TIME, profileUpdateTime)
}
@JvmStatic
fun shouldUpdateProfile(context: Context, profileUpdateTime: Long) =
profileUpdateTime > getLongPreference(context, LAST_PROFILE_UPDATE_TIME, 0)
// endregion
}