diff --git a/app/build.gradle b/app/build.gradle index 8c2123b45f..ebdf809ca5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,8 +28,8 @@ configurations.all { exclude module: "commons-logging" } -def canonicalVersionCode = 376 -def canonicalVersionName = "1.18.6" +def canonicalVersionCode = 377 +def canonicalVersionName = "1.19.0" def postFixSize = 10 def abiPostFix = ['armeabi-v7a' : 1, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 1870cf6260..82699f393a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -86,6 +86,7 @@ import org.thoughtcrime.securesms.sskenvironment.ProfileManager; import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager; import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository; import org.thoughtcrime.securesms.util.Broadcaster; +import org.thoughtcrime.securesms.util.VersionDataFetcher; import org.thoughtcrime.securesms.util.dynamiclanguage.LocaleParseHelper; import org.thoughtcrime.securesms.webrtc.CallMessageProcessor; import org.webrtc.PeerConnectionFactory; @@ -110,7 +111,6 @@ import javax.inject.Inject; import dagger.hilt.EntryPoints; import dagger.hilt.android.HiltAndroidApp; import kotlin.Unit; -import kotlinx.coroutines.Job; import network.loki.messenger.BuildConfig; import network.loki.messenger.libsession_util.ConfigBase; import network.loki.messenger.libsession_util.UserProfile; @@ -151,6 +151,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO @Inject PushRegistry pushRegistry; @Inject ConfigFactory configFactory; @Inject LastSentTimestampCache lastSentTimestampCache; + @Inject VersionDataFetcher versionDataFetcher; CallMessageProcessor callMessageProcessor; MessagingModuleConfiguration messagingModuleConfiguration; @@ -275,6 +276,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO OpenGroupManager.INSTANCE.startPolling(); }); + + // fetch last version data + versionDataFetcher.startTimedVersionCheck(); } @Override @@ -287,12 +291,14 @@ public class ApplicationContext extends Application implements DefaultLifecycleO poller.stopIfNeeded(); } ClosedGroupPollerV2.getShared().stopAll(); + versionDataFetcher.stopTimedVersionCheck(); } @Override public void onTerminate() { stopKovenant(); // Loki OpenGroupManager.INSTANCE.stopPolling(); + versionDataFetcher.stopTimedVersionCheck(); super.onTerminate(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt index e6dd1ae4d3..a3ba5fb639 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MuteDialog.kt @@ -7,8 +7,6 @@ import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY -import org.session.libsignal.utilities.Log -import java.time.Duration import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt index 1ffc65f592..8dffb1fd9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/NewConversationFragment.kt @@ -27,7 +27,11 @@ import org.thoughtcrime.securesms.groups.JoinCommunityFragment @AndroidEntryPoint class StartConversationFragment : BottomSheetDialogFragment(), StartConversationDelegate { - private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * 0.94).toInt() } + companion object{ + const val PEEK_RATIO = 0.94f + } + + private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * PEEK_RATIO).toInt() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt index 2c5437afdf..510b03bfaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessage.kt @@ -1,10 +1,13 @@ package org.thoughtcrime.securesms.conversation.start.newmessage -import androidx.compose.animation.AnimatedVisibility +import android.graphics.Rect +import android.os.Build +import android.view.ViewTreeObserver import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -15,23 +18,31 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import network.loki.messenger.R -import org.thoughtcrime.securesms.onboarding.ui.ContinuePrimaryOutlineButton +import org.thoughtcrime.securesms.conversation.start.StartConversationFragment.Companion.PEEK_RATIO import org.thoughtcrime.securesms.ui.LoadingArcOr -import org.thoughtcrime.securesms.ui.theme.LocalDimensions -import org.thoughtcrime.securesms.ui.theme.PreviewTheme -import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider -import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.components.AppBar import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode @@ -39,7 +50,13 @@ import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionTabRow import org.thoughtcrime.securesms.ui.contentDescription +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import kotlin.math.max private val TITLES = listOf(R.string.accountIdEnter, R.string.qrScan) @@ -76,63 +93,127 @@ private fun EnterAccountId( callbacks: Callbacks, onHelp: () -> Unit = {} ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .imePadding() - ) { - Column( - modifier = Modifier.padding(vertical = LocalDimensions.current.spacing), - horizontalAlignment = Alignment.CenterHorizontally, + // the scaffold is required to provide the contentPadding. That contentPadding is needed + // to properly handle the ime padding. + Scaffold() { contentPadding -> + // we need this extra surface to handle nested scrolling properly, + // because this scrollable component is inside a bottomSheet dialog which is itself scrollable + Surface( + modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()), + color = LocalColors.current.backgroundSecondary ) { - SessionOutlinedTextField( - text = state.newMessageIdOrOns, - modifier = Modifier - .padding(horizontal = LocalDimensions.current.spacing), - contentDescription = "Session id input box", - placeholder = stringResource(R.string.accountIdOrOnsEnter), - onChange = callbacks::onChange, - onContinue = callbacks::onContinue, - error = state.error?.string(), - isTextErrorColor = state.isTextErrorColor - ) - Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + var accountModifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) - BorderlessButtonWithIcon( - text = stringResource(R.string.messageNewDescriptionMobile), - modifier = Modifier - .contentDescription(R.string.AccessibilityId_help_desk_link) - .padding(horizontal = LocalDimensions.current.mediumSpacing) - .fillMaxWidth(), - style = LocalType.current.small, - color = LocalColors.current.textSecondary, - iconRes = R.drawable.ic_circle_question_mark, - onClick = onHelp - ) - } +//<<<<<<< HEAD +// BorderlessButtonWithIcon( +// text = stringResource(R.string.messageNewDescriptionMobile), +// modifier = Modifier +// .contentDescription(R.string.AccessibilityId_help_desk_link) +// .padding(horizontal = LocalDimensions.current.mediumSpacing) +// .fillMaxWidth(), +// style = LocalType.current.small, +// color = LocalColors.current.textSecondary, +// iconRes = R.drawable.ic_circle_question_mark, +// onClick = onHelp +// ) +// } +//======= + // There is a known issue with the ime padding on android versions below 30 + /// So on these older versions we need to resort to some manual padding based on the visible height + // when the keyboard is up + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + val keyboardHeight by keyboardHeight() + accountModifier = accountModifier.padding(bottom = keyboardHeight) + } else { + accountModifier = accountModifier + .consumeWindowInsets(contentPadding) + .imePadding() + } - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - Spacer(Modifier.weight(2f)) + Column( + modifier = accountModifier + ) { + Column( + modifier = Modifier.padding(vertical = LocalDimensions.current.spacing), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SessionOutlinedTextField( + text = state.newMessageIdOrOns, + modifier = Modifier + .padding(horizontal = LocalDimensions.current.spacing), + contentDescription = "Session id input box", + placeholder = stringResource(R.string.accountIdOrOnsEnter), + onChange = callbacks::onChange, + onContinue = callbacks::onContinue, + error = state.error?.string(), + isTextErrorColor = state.isTextErrorColor + ) - PrimaryOutlineButton( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(horizontal = LocalDimensions.current.xlargeSpacing) - .padding(bottom = LocalDimensions.current.smallSpacing) - .fillMaxWidth() - .contentDescription(R.string.next), - enabled = state.isNextButtonEnabled, - onClick = callbacks::onContinue - ) { - LoadingArcOr(state.loading) { - Text(stringResource(R.string.next)) + Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing)) + + BorderlessButtonWithIcon( + text = stringResource(R.string.messageNewDescriptionMobile), + modifier = Modifier + .contentDescription(R.string.AccessibilityId_help_desk_link) + .padding(horizontal = LocalDimensions.current.mediumSpacing) + .fillMaxWidth(), + style = LocalType.current.small, + color = LocalColors.current.textSecondary, + iconRes = R.drawable.ic_circle_question_mark, + onClick = onHelp + ) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) + Spacer(Modifier.weight(2f)) + + PrimaryOutlineButton( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(horizontal = LocalDimensions.current.xlargeSpacing) + .padding(bottom = LocalDimensions.current.smallSpacing) + .fillMaxWidth() + .contentDescription(R.string.next), + enabled = state.isNextButtonEnabled, + onClick = callbacks::onContinue + ) { + LoadingArcOr(state.loading) { + Text(stringResource(R.string.next)) + } + } } } } } +@Composable +fun keyboardHeight(): MutableState { + val view = LocalView.current + var keyboardHeight = remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + DisposableEffect(view) { + val listener = ViewTreeObserver.OnGlobalLayoutListener { + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height * PEEK_RATIO + val keypadHeightPx = max( screenHeight - rect.bottom, 0f) + + keyboardHeight.value = with(density) { keypadHeightPx.toDp() } + } + + view.viewTreeObserver.addOnGlobalLayoutListener(listener) + onDispose { + view.viewTreeObserver.removeOnGlobalLayoutListener(listener) + } + } + + return keyboardHeight +} + @Preview @Composable private fun PreviewNewMessage( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt index b2f2c9d013..7bd5fea914 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/start/newmessage/NewMessageViewModel.kt @@ -46,7 +46,7 @@ internal class NewMessageViewModel @Inject constructor( } override fun onContinue() { - val idOrONS = state.value.newMessageIdOrOns + val idOrONS = state.value.newMessageIdOrOns.trim() if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { onUnvalidatedPublicKey(publicKey = idOrONS) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 8633da7d58..7552551ace 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -178,7 +178,6 @@ import org.thoughtcrime.securesms.mms.VideoSlide import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment -import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities @@ -190,7 +189,6 @@ import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.show -import org.thoughtcrime.securesms.util.start import org.thoughtcrime.securesms.util.toPx private const val TAG = "ConversationActivityV2" @@ -1651,8 +1649,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val text = getMessageBody() val userPublicKey = textSecurePreferences.getLocalNumber() val isNoteToSelf = (recipient.isContactRecipient && recipient.address.toString() == userPublicKey) - if (text.contains(seed) && !isNoteToSelf && !hasPermissionToSendSeed) { - start() + if (seed in text && !isNoteToSelf && !hasPermissionToSendSeed) { + showSessionDialog { + title(R.string.warning) + text(R.string.recoveryPasswordWarningSendDescription) + button(R.string.send) { sendTextOnlyMessage(true) } + cancelButton() + } + + return null } // Create the message val message = VisibleMessage().applyExpiryMode(viewModel.threadId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt index 6a88ec9938..f7df0d3ecb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/Util.kt @@ -36,7 +36,6 @@ import com.google.android.mms.pdu_alt.EncodedStringValue import java.io.ByteArrayOutputStream import java.io.IOException import java.io.UnsupportedEncodingException -import java.security.SecureRandom import java.util.Collections import java.util.concurrent.TimeUnit import kotlin.math.max @@ -247,32 +246,6 @@ object Util { return result } - fun getSecretBytes(size: Int): ByteArray { - return getSecretBytes(SecureRandom(), size) - } - - fun getSecretBytes(secureRandom: SecureRandom, size: Int): ByteArray { - val secret = ByteArray(size) - secureRandom.nextBytes(secret) - return secret - } - - fun getRandomElement(elements: Array): T { - return elements[SecureRandom().nextInt(elements.size)] - } - - fun getRandomElement(elements: List): T { - return elements[SecureRandom().nextInt(elements.size)] - } - - fun equals(a: Any?, b: Any?): Boolean { - return a === b || (a != null && a == b) - } - - fun hashCode(vararg objects: Any?): Int { - return objects.contentHashCode() - } - fun uri(uri: String?): Uri? { return if (uri == null) null else Uri.parse(uri) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt deleted file mode 100644 index 177d81a2f0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/dialogs/SendSeedDialog.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.thoughtcrime.securesms.conversation.v2.dialogs - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import network.loki.messenger.R -import org.thoughtcrime.securesms.createSessionDialog - -/** Shown if the user is about to send their recovery phrase to someone. */ -class SendSeedDialog(private val proceed: (() -> Unit)? = null) : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog { - title(R.string.warning) - text(R.string.recoveryPasswordWarningSendDescription) - button(R.string.send) { send() } - cancelButton() - } - - private fun send() { - proceed?.invoke() - dismiss() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecretProvider.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecretProvider.java index 0344551cab..1602ab3fcf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecretProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/AttachmentSecretProvider.java @@ -1,14 +1,14 @@ package org.thoughtcrime.securesms.crypto; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; + import android.content.Context; import android.os.Build; import androidx.annotation.NonNull; import org.session.libsession.utilities.TextSecurePreferences; -import java.security.SecureRandom; - /** * A provider that is responsible for creating or retrieving the AttachmentSecret model. * @@ -81,9 +81,8 @@ public class AttachmentSecretProvider { } private AttachmentSecret createAndStoreAttachmentSecret(@NonNull Context context) { - SecureRandom random = new SecureRandom(); byte[] secret = new byte[32]; - random.nextBytes(secret); + SECURE_RANDOM.nextBytes(secret); AttachmentSecret attachmentSecret = new AttachmentSecret(null, null, secret); storeAttachmentSecret(context, attachmentSecret); diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java index 0ad22c3a90..44bb33161c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/DatabaseSecretProvider.java @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.crypto; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; + import android.content.Context; import android.os.Build; import androidx.annotation.NonNull; @@ -8,7 +10,6 @@ import androidx.annotation.NonNull; import org.session.libsession.utilities.TextSecurePreferences; import java.io.IOException; -import java.security.SecureRandom; public class DatabaseSecretProvider { @@ -60,9 +61,8 @@ public class DatabaseSecretProvider { } private DatabaseSecret createAndStoreDatabaseSecret(@NonNull Context context) { - SecureRandom random = new SecureRandom(); byte[] secret = new byte[32]; - random.nextBytes(secret); + SECURE_RANDOM.nextBytes(secret); DatabaseSecret databaseSecret = new DatabaseSecret(secret); diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernEncryptingPartOutputStream.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernEncryptingPartOutputStream.java index ab4efe84a9..0bd62f2143 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernEncryptingPartOutputStream.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/ModernEncryptingPartOutputStream.java @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.crypto; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; + import androidx.annotation.NonNull; import android.util.Pair; @@ -11,7 +13,6 @@ import java.io.OutputStream; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; import javax.crypto.Cipher; import javax.crypto.CipherOutputStream; @@ -31,7 +32,7 @@ public class ModernEncryptingPartOutputStream { throws IOException { byte[] random = new byte[32]; - new SecureRandom().nextBytes(random); + SECURE_RANDOM.nextBytes(random); try { Mac mac = Mac.getInstance("HmacSHA256"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 66d01114ef..6e1f72c568 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.database; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; + import android.annotation.SuppressLint; import android.content.ContentValues; import android.content.Context; @@ -26,7 +28,6 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.util.BitmapUtil; import java.io.Closeable; -import java.security.SecureRandom; import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -303,7 +304,7 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt public void updateProfilePicture(String groupID, byte[] newValue) { long avatarId; - if (newValue != null) avatarId = Math.abs(new SecureRandom().nextLong()); + if (newValue != null) avatarId = Math.abs(SECURE_RANDOM.nextLong()); else avatarId = 0; @@ -458,12 +459,6 @@ public class GroupDatabase extends Database implements LokiOpenGroupDatabaseProt database.update(TABLE_NAME, values, GROUP_ID + " = ?", new String[] {groupId}); } - public byte[] allocateGroupId() { - byte[] groupId = new byte[16]; - new SecureRandom().nextBytes(groupId); - return groupId; - } - public boolean hasGroup(@NonNull String groupId) { try (Cursor cursor = databaseHelper.getReadableDatabase().rawQuery( "SELECT 1 FROM " + TABLE_NAME + " WHERE " + GROUP_ID + " = ? LIMIT 1", diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index f1f999242c..ff22ae14e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -166,8 +166,6 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( const val RESET_SEQ_NO = "UPDATE $lastMessageServerIDTable SET $lastMessageServerID = 0;" - const val EMPTY_VERSION = "0.0.0" - // endregion } @@ -175,15 +173,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( val database = databaseHelper.readableDatabase return database.get(snodePoolTable, "${Companion.dummyKey} = ?", wrap("dummy_key")) { cursor -> val snodePoolAsString = cursor.getString(cursor.getColumnIndexOrThrow(snodePool)) - snodePoolAsString.split(", ").mapNotNull { snodeAsString -> - val components = snodeAsString.split("-") - val address = components[0] - val port = components.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null - val ed25519Key = components.getOrNull(2) ?: return@mapNotNull null - val x25519Key = components.getOrNull(3) ?: return@mapNotNull null - val version = components.getOrNull(4) ?: EMPTY_VERSION - Snode(address, port, Snode.KeySet(ed25519Key, x25519Key), version) - } + snodePoolAsString.split(", ").mapNotNull(::Snode) }?.toSet() ?: setOf() } @@ -231,18 +221,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( val database = databaseHelper.readableDatabase fun get(indexPath: String): Snode? { return database.get(onionRequestPathTable, "${Companion.indexPath} = ?", wrap(indexPath)) { cursor -> - val snodeAsString = cursor.getString(cursor.getColumnIndexOrThrow(snode)) - val components = snodeAsString.split("-") - val address = components[0] - val port = components.getOrNull(1)?.toIntOrNull() - val ed25519Key = components.getOrNull(2) - val x25519Key = components.getOrNull(3) - val version = components.getOrNull(4) ?: EMPTY_VERSION - if (port != null && ed25519Key != null && x25519Key != null) { - Snode(address, port, Snode.KeySet(ed25519Key, x25519Key), version) - } else { - null - } + Snode(cursor.getString(cursor.getColumnIndexOrThrow(snode))) } } val result = mutableListOf>() @@ -276,15 +255,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database( val database = databaseHelper.readableDatabase return database.get(swarmTable, "${Companion.swarmPublicKey} = ?", wrap(publicKey)) { cursor -> val swarmAsString = cursor.getString(cursor.getColumnIndexOrThrow(swarm)) - swarmAsString.split(", ").mapNotNull { targetAsString -> - val components = targetAsString.split("-") - val address = components[0] - val port = components.getOrNull(1)?.toIntOrNull() ?: return@mapNotNull null - val ed25519Key = components.getOrNull(2) ?: return@mapNotNull null - val x25519Key = components.getOrNull(3) ?: return@mapNotNull null - val version = components.getOrNull(4) ?: EMPTY_VERSION - Snode(address, port, Snode.KeySet(ed25519Key, x25519Key), version) - } + swarmAsString.split(", ").mapNotNull(::Snode) }?.toSet() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 9d3a6c9c18..5a2a9155de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -46,11 +46,11 @@ import org.session.libsession.utilities.IdentityKeyMismatchList import org.session.libsession.utilities.NetworkFailure import org.session.libsession.utilities.NetworkFailureList import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled -import org.session.libsession.utilities.Util.toIsoBytes import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue +import org.session.libsignal.utilities.Util.SECURE_RANDOM import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment import org.thoughtcrime.securesms.database.SmsDatabase.InsertListener @@ -66,7 +66,6 @@ import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.util.asSequence import java.io.Closeable import java.io.IOException -import java.security.SecureRandom import java.util.LinkedList class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : MessagingDatabase(context, databaseHelper) { @@ -1200,7 +1199,7 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa inner class OutgoingMessageReader(private val message: OutgoingMediaMessage?, private val threadId: Long) { - private val id = SecureRandom().nextLong() + private val id = SECURE_RANDOM.nextLong() val current: MessageRecord get() { val slideDeck = SlideDeck(context, message!!.attachments) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index 84b9441834..f02498112f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -17,6 +17,8 @@ */ package org.thoughtcrime.securesms.database; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; + import android.content.ContentValues; import android.content.Context; import android.database.Cursor; @@ -49,7 +51,6 @@ import org.thoughtcrime.securesms.database.model.SmsMessageRecord; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.io.Closeable; import java.io.IOException; -import java.security.SecureRandom; import java.util.Arrays; import java.util.Collections; import java.util.LinkedList; @@ -784,7 +785,7 @@ public class SmsDatabase extends MessagingDatabase { public OutgoingMessageReader(OutgoingTextMessage message, long threadId) { this.message = message; this.threadId = threadId; - this.id = new SecureRandom().nextLong(); + this.id = SECURE_RANDOM.nextLong(); } public MessageRecord getCurrent() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/PaddedHeadersInterceptor.java b/app/src/main/java/org/thoughtcrime/securesms/glide/PaddedHeadersInterceptor.java index 5d0ab584b7..9c347eb414 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/PaddedHeadersInterceptor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/PaddedHeadersInterceptor.java @@ -1,9 +1,10 @@ package org.thoughtcrime.securesms.glide; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; + import androidx.annotation.NonNull; import java.io.IOException; -import java.security.SecureRandom; import okhttp3.Headers; import okhttp3.Interceptor; @@ -30,15 +31,15 @@ public class PaddedHeadersInterceptor implements Interceptor { private @NonNull Headers getPaddedHeaders(@NonNull Headers headers) { return headers.newBuilder() - .add(PADDING_HEADER, getRandomString(new SecureRandom(), MIN_RANDOM_BYTES, MAX_RANDOM_BYTES)) + .add(PADDING_HEADER, getRandomString(MIN_RANDOM_BYTES, MAX_RANDOM_BYTES)) .build(); } - private static @NonNull String getRandomString(@NonNull SecureRandom secureRandom, int minLength, int maxLength) { - char[] buffer = new char[secureRandom.nextInt(maxLength - minLength) + minLength]; + private static @NonNull String getRandomString(int minLength, int maxLength) { + char[] buffer = new char[SECURE_RANDOM.nextInt(maxLength - minLength) + minLength]; for (int i = 0 ; i < buffer.length; i++) { - buffer[i] = (char) (secureRandom.nextInt(74) + 48); // Random char from 0-Z + buffer[i] = (char) (SECURE_RANDOM.nextInt(74) + 48); // Random char from 0-Z } return new String(buffer); diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 98bce20bd7..b4ddf80b84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -89,6 +89,9 @@ import java.util.Calendar import java.util.Locale import javax.inject.Inject +private const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT" +private const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" + @AndroidEntryPoint class HomeActivity : PassphraseRequiredActionBarActivity(), ConversationClickListener, @@ -96,10 +99,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), private val TAG = "HomeActivity" - companion object { - const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT" - const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" - } +// companion object { +// const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT" +// const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING" +// } private lateinit var binding: ActivityHomeBinding private lateinit var glide: RequestManager @@ -148,7 +151,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } - private val isNewAccount: Boolean get() = intent.getBooleanExtra(FROM_ONBOARDING, false) + private val isFromOnboarding: Boolean get() = intent.getBooleanExtra(FROM_ONBOARDING, false) + private val isNewAccount: Boolean get() = intent.getBooleanExtra(NEW_ACCOUNT, false) // region Lifecycle override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { @@ -262,7 +266,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } else -> buildList { result.contactAndGroupList.takeUnless { it.isEmpty() }?.let { - add(GlobalSearchAdapter.Model.Header(R.string.contactContacts)) + add(GlobalSearchAdapter.Model.Header(R.string.sessionConversations)) addAll(it) } result.messageResults.takeUnless { it.isEmpty() }?.let { @@ -275,8 +279,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } EventBus.getDefault().register(this@HomeActivity) - if (intent.hasExtra(FROM_ONBOARDING) - && intent.getBooleanExtra(FROM_ONBOARDING, false)) { + if (isFromOnboarding) { if (Build.VERSION.SDK_INT >= 33 && (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled().not()) { Permissions.with(this) @@ -681,10 +684,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } } -fun Context.startHomeActivity(isNewAccount: Boolean) { +fun Context.startHomeActivity(isFromOnboarding: Boolean, isNewAccount: Boolean) { Intent(this, HomeActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - putExtra(HomeActivity.NEW_ACCOUNT, true) - putExtra(HomeActivity.FROM_ONBOARDING, true) + putExtra(NEW_ACCOUNT, isNewAccount) + putExtra(FROM_ONBOARDING, isFromOnboarding) }.also(::startActivity) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java b/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java index 909f19e08c..cfe6cc3809 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/LogFile.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.logging; import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; import androidx.annotation.NonNull; @@ -17,7 +18,6 @@ import java.io.IOException; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; @@ -64,7 +64,7 @@ class LogFile { } void writeEntry(@NonNull String entry) throws IOException { - new SecureRandom().nextBytes(ivBuffer); + SECURE_RANDOM.nextBytes(ivBuffer); byte[] plaintext = entry.getBytes(); try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/logging/LogSecretProvider.java b/app/src/main/java/org/thoughtcrime/securesms/logging/LogSecretProvider.java index aabd2811db..763a2a430d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logging/LogSecretProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logging/LogSecretProvider.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.logging; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; + import android.content.Context; import android.os.Build; import androidx.annotation.NonNull; @@ -9,7 +11,6 @@ import org.session.libsignal.utilities.Base64; import org.session.libsession.utilities.TextSecurePreferences; import java.io.IOException; -import java.security.SecureRandom; class LogSecretProvider { @@ -40,9 +41,8 @@ class LogSecretProvider { } private static byte[] createAndStoreSecret(@NonNull Context context) { - SecureRandom random = new SecureRandom(); byte[] secret = new byte[32]; - random.nextBytes(secret); + SECURE_RANDOM.nextBytes(secret); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(secret); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt index bc870f07d4..9b3db2efd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt @@ -21,7 +21,6 @@ import android.content.res.Resources import android.net.Uri import androidx.annotation.DrawableRes import com.squareup.phrase.Phrase -import java.security.SecureRandom import network.loki.messenger.R import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress @@ -29,6 +28,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.UriAttachm import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.session.libsession.utilities.Util.equals import org.session.libsession.utilities.Util.hashCode +import org.session.libsignal.utilities.Util.SECURE_RANDOM import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.conversation.v2.Util import org.thoughtcrime.securesms.util.MediaUtil @@ -160,7 +160,7 @@ abstract class Slide(@JvmField protected val context: Context, protected val att ): Attachment { val resolvedType = Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime) - val fastPreflightId = SecureRandom().nextLong().toString() + val fastPreflightId = SECURE_RANDOM.nextLong().toString() return UriAttachment( uri, if (hasThumbnail) uri else null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java index af37b86394..e50846b1c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java +++ b/app/src/main/java/org/thoughtcrime/securesms/net/ChunkedDataFetcher.java @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.net; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; + import androidx.annotation.NonNull; import android.text.TextUtils; @@ -15,7 +17,6 @@ import org.session.libsignal.utilities.guava.Optional; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; -import java.security.SecureRandom; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; @@ -54,7 +55,7 @@ public class ChunkedDataFetcher { private RequestController fetchChunksWithUnknownTotalSize(@NonNull String url, @NonNull Callback callback) { CompositeRequestController compositeController = new CompositeRequestController(); - long chunkSize = new SecureRandom().nextInt(1024) + 1024; + long chunkSize = SECURE_RANDOM.nextInt(1024) + 1024; Request request = new Request.Builder() .url(url) .cacheControl(NO_CACHE) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt index 9c8a1869d4..abf0471598 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loading/LoadingActivity.kt @@ -32,7 +32,7 @@ class LoadingActivity: BaseActionBarActivity() { when { loadFailed -> startPickDisplayNameActivity(loadFailed = true) - else -> startHomeActivity(isNewAccount = false) + else -> startHomeActivity(isNewAccount = false, isFromOnboarding = true) } finish() diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt index 1e0a21d571..98e9c8b20d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt @@ -8,6 +8,7 @@ import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.util.VersionDataFetcher import javax.inject.Inject import javax.inject.Singleton @@ -16,6 +17,7 @@ class CreateAccountManager @Inject constructor( private val application: Application, private val prefs: TextSecurePreferences, private val configFactory: ConfigFactory, + private val versionDataFetcher: VersionDataFetcher ) { private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -41,5 +43,7 @@ class CreateAccountManager @Inject constructor( prefs.setLocalRegistrationId(registrationID) prefs.setLocalNumber(userHexEncodedPublicKey) prefs.setRestorationTime(0) + + versionDataFetcher.startTimedVersionCheck() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt index 5a40103830..51d1b24609 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt @@ -12,6 +12,7 @@ import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.crypto.KeyPairUtilities import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.util.VersionDataFetcher import javax.inject.Inject import javax.inject.Singleton @@ -19,7 +20,8 @@ import javax.inject.Singleton class LoadAccountManager @Inject constructor( @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context, private val configFactory: ConfigFactory, - private val prefs: TextSecurePreferences + private val prefs: TextSecurePreferences, + private val versionDataFetcher: VersionDataFetcher ) { private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -52,6 +54,8 @@ class LoadAccountManager @Inject constructor( setHasViewedSeed(true) } + versionDataFetcher.startTimedVersionCheck() + ApplicationContext.getInstance(context).retrieveUserProfile() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt index 42cf49bce8..9ea4fd2bd6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotificationsActivity.kt @@ -49,7 +49,7 @@ class MessageNotificationsActivity : BaseActionBarActivity() { viewModel.events.collect { when (it) { Event.Loading -> start() - Event.OnboardingComplete -> startHomeActivity(isNewAccount = true) + Event.OnboardingComplete -> startHomeActivity(isNewAccount = true, isFromOnboarding = true) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt index 97aacf82e6..8ade5b4e8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/pickname/PickDisplayNameActivity.kt @@ -45,7 +45,7 @@ class PickDisplayNameActivity : BaseActionBarActivity() { viewModel.events.collect { when (it) { is Event.CreateAccount -> startMessageNotificationsActivity(it.profileName) - Event.LoadAccountComplete -> startHomeActivity(isNewAccount = false) + Event.LoadAccountComplete -> startHomeActivity(isNewAccount = false, isFromOnboarding = true) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java index 88ee67cb4d..9b6950bf7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/permissions/Permissions.java @@ -1,9 +1,9 @@ package org.thoughtcrime.securesms.permissions; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; + import android.app.Activity; -import android.app.AlertDialog; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; @@ -11,9 +11,7 @@ import android.os.Build; import android.provider.Settings; import android.util.DisplayMetrics; import android.view.Display; -import android.view.ViewGroup; import android.view.WindowManager; -import android.widget.Button; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; @@ -28,13 +26,10 @@ import org.session.libsession.utilities.ServiceUtil; import org.thoughtcrime.securesms.util.LRUCache; import java.lang.ref.WeakReference; -import java.security.SecureRandom; import java.util.Arrays; import java.util.List; import java.util.Map; -import network.loki.messenger.R; - public class Permissions { private static final Map OUTSTANDING = new LRUCache<>(2); @@ -172,7 +167,7 @@ public class Permissions { } private void executePermissionsRequest(PermissionsRequest request) { - int requestCode = new SecureRandom().nextInt(65434) + 100; + int requestCode = SECURE_RANDOM.nextInt(65434) + 100; synchronized (OUTSTANDING) { OUTSTANDING.put(requestCode, request); diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 5f88afdb14..eacc0c8d03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -37,7 +37,6 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint import java.io.File -import java.security.SecureRandom import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose @@ -67,6 +66,7 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.truncateIdForDisplay import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Util.SECURE_RANDOM import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.avatar.AvatarSelection import org.thoughtcrime.securesms.components.ProfilePictureView @@ -300,7 +300,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { val userConfig = configFactory.user AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture) - prefs.setProfileAvatarId(SecureRandom().nextInt() ) + prefs.setProfileAvatarId(SECURE_RANDOM.nextInt() ) ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey) // Attempt to grab the details we require to update the profile picture diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt index afe33400ff..2547e23e22 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppearanceSettingsViewModel.kt @@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.session.libsession.utilities.TextSecurePreferences -import org.thoughtcrime.securesms.ui.theme.selectedTheme +import org.thoughtcrime.securesms.ui.theme.invalidateComposeThemeColors import org.thoughtcrime.securesms.util.ThemeState import org.thoughtcrime.securesms.util.themeState import javax.inject.Inject @@ -21,6 +21,8 @@ class AppearanceSettingsViewModel @Inject constructor(private val prefs: TextSec prefs.setAccentColorStyle(newAccentColorStyle) // update UI state _uiState.value = prefs.themeState() + + invalidateComposeThemeColors() } fun setNewStyle(newThemeStyle: String) { @@ -28,16 +30,13 @@ class AppearanceSettingsViewModel @Inject constructor(private val prefs: TextSec // update UI state _uiState.value = prefs.themeState() - // force compose to refresh its style reference - selectedTheme = null + invalidateComposeThemeColors() } fun setNewFollowSystemSettings(followSystemSettings: Boolean) { prefs.setFollowSystemSettings(followSystemSettings) _uiState.value = prefs.themeState() - // force compose to refresh its style reference - selectedTheme = null + invalidateComposeThemeColors() } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColorSet.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColorSet.kt deleted file mode 100644 index 3ff4d55b61..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColorSet.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.thoughtcrime.securesms.ui.theme - -/** - * This class holds two instances of [ThemeColors], [light] representing the [ThemeColors] to use when the system is in a - * light theme, and [dark] representing the [ThemeColors] to use when the system is in a dark theme. - */ -data class ThemeColorSet( - val light: ThemeColors, - val dark: ThemeColors -) \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColorsProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColorsProvider.kt new file mode 100644 index 0000000000..0713254afc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColorsProvider.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable + +fun interface ThemeColorsProvider { + @Composable + fun get(): ThemeColors +} + +@Suppress("FunctionName") +fun FollowSystemThemeColorsProvider(light: ThemeColors, dark: ThemeColors) = ThemeColorsProvider { + when { + isSystemInDarkTheme() -> dark + else -> light + } +} + +@Suppress("FunctionName") +fun ThemeColorsProvider(colors: ThemeColors) = ThemeColorsProvider { colors } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt index 8988e84ca7..403235ef79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeFromPreferences.kt @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.ui.theme -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.BLUE_ACCENT @@ -17,38 +15,25 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.YELLOW_A * Some behaviour is hardcoded to cater for legacy usage of people with themes already set * But future themes will be picked and set directly from the "Appearance" screen */ -@Composable -fun TextSecurePreferences.getComposeTheme(): ThemeColors { +fun TextSecurePreferences.getColorsProvider(): ThemeColorsProvider { val selectedTheme = getThemeStyle() // get the chosen primary color from the preferences val selectedPrimary = primaryColor() - // create a theme set with the appropriate primary - val colorSet = when(selectedTheme){ - TextSecurePreferences.OCEAN_DARK, - TextSecurePreferences.OCEAN_LIGHT -> ThemeColorSet( - light = OceanLight(selectedPrimary), - dark = OceanDark(selectedPrimary) - ) - - else -> ThemeColorSet( - light = ClassicLight(selectedPrimary), - dark = ClassicDark(selectedPrimary) + val isOcean = "ocean" in selectedTheme + + val createLight = if (isOcean) ::OceanLight else ::ClassicLight + val createDark = if (isOcean) ::OceanDark else ::ClassicDark + + return when { + getFollowSystemSettings() -> FollowSystemThemeColorsProvider( + light = createLight(selectedPrimary), + dark = createDark(selectedPrimary) ) + "light" in selectedTheme -> ThemeColorsProvider(createLight(selectedPrimary)) + else -> ThemeColorsProvider(createDark(selectedPrimary)) } - - // deliver the right set from the light/dark mode chosen - val theme = when{ - getFollowSystemSettings() -> if(isSystemInDarkTheme()) colorSet.dark else colorSet.light - - selectedTheme == TextSecurePreferences.CLASSIC_LIGHT || - selectedTheme == TextSecurePreferences.OCEAN_LIGHT -> colorSet.light - - else -> colorSet.dark - } - - return theme } fun TextSecurePreferences.primaryColor(): Color = when(getSelectedAccentColor()) { @@ -60,6 +45,3 @@ fun TextSecurePreferences.primaryColor(): Color = when(getSelectedAccentColor()) YELLOW_ACCENT -> primaryYellow else -> primaryGreen } - - - diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt index 87e91e0ab0..2f4957565b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/Themes.kt @@ -20,7 +20,12 @@ import org.session.libsession.utilities.AppTextSecurePreferences val LocalColors = compositionLocalOf { ClassicDark() } val LocalType = compositionLocalOf { sessionTypography } -var selectedTheme: ThemeColors? = null +var cachedColorsProvider: ThemeColorsProvider? = null + +fun invalidateComposeThemeColors() { + // invalidate compose theme colors + cachedColorsProvider = null +} /** * Apply a Material2 compose theme based on user selections in SharedPreferences. @@ -29,15 +34,15 @@ var selectedTheme: ThemeColors? = null fun SessionMaterialTheme( content: @Composable () -> Unit ) { - // set the theme data if it hasn't been done yet - if(selectedTheme == null) { - // Some values can be set from the preferences, and if not should fallback to a default value - val context = LocalContext.current - val preferences = AppTextSecurePreferences(context) - selectedTheme = preferences.getComposeTheme() - } + val context = LocalContext.current + val preferences = AppTextSecurePreferences(context) - SessionMaterialTheme(colors = selectedTheme ?: ClassicDark()) { content() } + val cachedColors = cachedColorsProvider ?: preferences.getColorsProvider().also { cachedColorsProvider = it } + + SessionMaterialTheme( + colors = cachedColors.get(), + content = content + ) } /** @@ -58,9 +63,8 @@ fun SessionMaterialTheme( LocalType provides sessionTypography, LocalContentColor provides colors.text, LocalTextSelectionColors provides colors.textSelectionColors, - ) { - content() - } + content = content + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt new file mode 100644 index 0000000000..aba814524c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/VersionDataFetcher.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.util + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.session.libsession.messaging.file_server.FileServerApi +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours + +private val TAG: String = VersionDataFetcher::class.java.simpleName +private val REFRESH_TIME_MS = 4.hours.inWholeMilliseconds + +@Singleton +class VersionDataFetcher @Inject constructor( + private val prefs: TextSecurePreferences +) { + private val handler = Handler(Looper.getMainLooper()) + private val fetchVersionData = Runnable { + scope.launch { + try { + // Perform the version check + val clientVersion = FileServerApi.getClientVersion() + Log.i(TAG, "Fetched version data: $clientVersion") + prefs.setLastVersionCheck() + startTimedVersionCheck() + } catch (e: Exception) { + // We can silently ignore the error + Log.e(TAG, "Error fetching version data", e) + // Schedule the next check for 4 hours from now, but do not setLastVersionCheck + // so the app will retry when the app is next foregrounded. + startTimedVersionCheck(REFRESH_TIME_MS) + } + } + } + + private val scope = CoroutineScope(Dispatchers.Default) + + /** + * Schedules fetching version data. + * + * @param delayMillis The delay before fetching version data. Default value is 4 hours from the + * last check or 0 if there was no previous check or if it was longer than 4 hours ago. + */ + @JvmOverloads + fun startTimedVersionCheck( + delayMillis: Long = REFRESH_TIME_MS + prefs.getLastVersionCheck() - System.currentTimeMillis() + ) { + stopTimedVersionCheck() + handler.postDelayed(fetchVersionData, delayMillis) + } + + fun stopTimedVersionCheck() { + handler.removeCallbacks(fetchVersionData) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt index b61edbb6d2..24ea2a390f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt @@ -1,8 +1,10 @@ package org.thoughtcrime.securesms.webrtc import android.content.Context +import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.SettableFuture +import org.session.libsignal.utilities.Util.SECURE_RANDOM import org.thoughtcrime.securesms.webrtc.video.Camera import org.thoughtcrime.securesms.webrtc.video.CameraEventListener import org.thoughtcrime.securesms.webrtc.video.CameraState @@ -22,9 +24,7 @@ import org.webrtc.SurfaceTextureHelper import org.webrtc.VideoSink import org.webrtc.VideoSource import org.webrtc.VideoTrack -import java.security.SecureRandom import java.util.concurrent.ExecutionException -import kotlin.random.asKotlinRandom class PeerConnectionWrapper(private val context: Context, private val factory: PeerConnectionFactory, @@ -49,8 +49,7 @@ class PeerConnectionWrapper(private val context: Context, private var isInitiator = false private fun initPeerConnection() { - val random = SecureRandom().asKotlinRandom() - val iceServers = listOf("freyr","angus","hereford","holstein", "brahman").shuffled(random).take(2).map { sub -> + val iceServers = listOf("freyr","angus","hereford","holstein", "brahman").shuffledRandom().take(2).map { sub -> PeerConnection.IceServer.builder("turn:$sub.getsession.org") .setUsername("session202111") .setPassword("053c268164bc7bd7") diff --git a/app/src/test/java/org/session/libsignal/utilities/SnodeTest.kt b/app/src/test/java/org/session/libsignal/utilities/SnodeTest.kt new file mode 100644 index 0000000000..aed54fd0d3 --- /dev/null +++ b/app/src/test/java/org/session/libsignal/utilities/SnodeTest.kt @@ -0,0 +1,52 @@ +package org.session.libsignal.utilities + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.IsEqual.equalTo +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class SnodeVersionTest( + private val v1: String, + private val v2: String, + private val expectedEqual: Boolean, + private val expectedLessThan: Boolean +) { + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: testVersion({0},{1}) = (equalTo: {2}, lessThan: {3})") + fun data(): Collection> = listOf( + arrayOf("1", "1", true, false), + arrayOf("1", "2", false, true), + arrayOf("2", "1", false, false), + arrayOf("1.0", "1", true, false), + arrayOf("1.0", "1.0.0", true, false), + arrayOf("1.0", "1.0.0.0", true, false), + arrayOf("1.0", "1.0.0.0.0.0", true, false), + arrayOf("2.0", "1.2", false, false), + arrayOf("1.0.0.0", "1.0.0.1", false, true), + // Snode.Version only considers the first 4 integers, so these are equal + arrayOf("1.0.0.0", "1.0.0.0.1", true, false), + arrayOf("1.0.0.1", "1.0.0.1", true, false), + // parts can be up to 16 bits, around 65,535 + arrayOf("65535.65535.65535.65535", "65535.65535.65535.65535", true, false), + // values higher than this are coerced to 65535 (: + arrayOf("65535.65535.65535.65535", "65535.65535.65535.99999", true, false), + ) + } + + @Test + fun testVersionEqual() { + val version1 = Snode.Version(v1) + val version2 = Snode.Version(v2) + assertThat(version1 == version2, equalTo(expectedEqual)) + } + + @Test + fun testVersionOnePartLessThan() { + val version1 = Snode.Version(v1) + val version2 = Snode.Version(v2) + assertThat(version1 < version2, equalTo(expectedLessThan)) + } +} \ No newline at end of file diff --git a/libsession-util/libsession-util b/libsession-util/libsession-util index 626b6628a2..0193c36e0d 160000 --- a/libsession-util/libsession-util +++ b/libsession-util/libsession-util @@ -1 +1 @@ -Subproject commit 626b6628a2af8fff798042416b3b469b8bfc6ecf +Subproject commit 0193c36e0dad461385d6407a00f33b7314e6d740 diff --git a/libsession-util/src/main/cpp/CMakeLists.txt b/libsession-util/src/main/cpp/CMakeLists.txt index 47fee4803c..f65667bcb5 100644 --- a/libsession-util/src/main/cpp/CMakeLists.txt +++ b/libsession-util/src/main/cpp/CMakeLists.txt @@ -30,6 +30,7 @@ set(SOURCES config_base.cpp contacts.cpp conversation.cpp + blinded_key.cpp util.cpp) add_library( # Sets the name of the library. diff --git a/libsession-util/src/main/cpp/blinded_key.cpp b/libsession-util/src/main/cpp/blinded_key.cpp new file mode 100644 index 0000000000..1ed1cfd554 --- /dev/null +++ b/libsession-util/src/main/cpp/blinded_key.cpp @@ -0,0 +1,34 @@ +#include +#include + +#include "util.h" +#include "jni_utils.h" + +// +// Created by Thomas Ruffie on 29/7/2024. +// + +extern "C" +JNIEXPORT jobject JNICALL +Java_network_loki_messenger_libsession_1util_util_BlindKeyAPI_blindVersionKeyPair(JNIEnv *env, + jobject thiz, + jbyteArray ed25519_secret_key) { + return jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + const auto [pk, sk] = session::blind_version_key_pair(util::ustring_from_bytes(env, ed25519_secret_key)); + + jclass kp_class = env->FindClass("network/loki/messenger/libsession_util/util/KeyPair"); + jmethodID kp_constructor = env->GetMethodID(kp_class, "", "([B[B)V"); + return env->NewObject(kp_class, kp_constructor, util::bytes_from_ustring(env, {pk.data(), pk.size()}), util::bytes_from_ustring(env, {sk.data(), sk.size()})); + }); +} +extern "C" +JNIEXPORT jbyteArray JNICALL +Java_network_loki_messenger_libsession_1util_util_BlindKeyAPI_blindVersionSign(JNIEnv *env, + jobject thiz, + jbyteArray ed25519_secret_key, + jlong timestamp) { + return jni_utils::run_catching_cxx_exception_or_throws(env, [=] { + auto bytes = session::blind_version_sign(util::ustring_from_bytes(env, ed25519_secret_key), session::Platform::android, timestamp); + return util::bytes_from_ustring(env, bytes); + }); +} \ No newline at end of file diff --git a/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BlindKeyAPI.kt b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BlindKeyAPI.kt new file mode 100644 index 0000000000..cd3dac3af2 --- /dev/null +++ b/libsession-util/src/main/java/network/loki/messenger/libsession_util/util/BlindKeyAPI.kt @@ -0,0 +1,15 @@ +package network.loki.messenger.libsession_util.util + +object BlindKeyAPI { + private val loadLibrary by lazy { + System.loadLibrary("session_util") + } + + init { + // Ensure the library is loaded at initialization + loadLibrary + } + + external fun blindVersionKeyPair(ed25519SecretKey: ByteArray): KeyPair + external fun blindVersionSign(ed25519SecretKey: ByteArray, timestamp: Long): ByteArray +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt index 5bffed57ee..6a9c95aa56 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/file_server/FileServerApi.kt @@ -1,18 +1,22 @@ package org.session.libsession.messaging.file_server +import android.util.Base64 +import network.loki.messenger.libsession_util.util.BlindKeyAPI import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.map -import okhttp3.Headers import okhttp3.Headers.Companion.toHeaders import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody +import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.snode.OnionRequestAPI +import org.session.libsession.snode.utilities.await import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.toHexString +import kotlin.time.Duration.Companion.milliseconds object FileServerApi { @@ -23,6 +27,7 @@ object FileServerApi { sealed class Error(message: String) : Exception(message) { object ParsingFailed : Error("Invalid response.") object InvalidURL : Error("Invalid URL.") + object NoEd25519KeyPair : Error("Couldn't find ed25519 key pair.") } data class Request( @@ -105,4 +110,50 @@ object FileServerApi { val request = Request(verb = HTTP.Verb.GET, endpoint = "file/$file") return send(request) } + + /** + * Returns the current version of session + * This is effectively proxying (and caching) the response from the github release + * page. + * + * Note that the value is cached and can be up to 30 minutes out of date normally, and up to 24 + * hours out of date if we cannot reach the Github API for some reason. + * + * https://github.com/oxen-io/session-file-server/blob/dev/doc/api.yaml#L119 + */ + suspend fun getClientVersion(): VersionData { + // Generate the auth signature + val secretKey = MessagingModuleConfiguration.shared.getUserED25519KeyPair()?.secretKey?.asBytes + ?: throw (Error.NoEd25519KeyPair) + + val blindedKeys = BlindKeyAPI.blindVersionKeyPair(secretKey) + val timestamp = System.currentTimeMillis().milliseconds.inWholeSeconds // The current timestamp in seconds + val signature = BlindKeyAPI.blindVersionSign(secretKey, timestamp) + + // The hex encoded version-blinded public key with a 07 prefix + val blindedPkHex = "07" + blindedKeys.pubKey.toHexString() + + val request = Request( + verb = HTTP.Verb.GET, + endpoint = "session_version", + queryParameters = mapOf("platform" to "android"), + headers = mapOf( + "X-FS-Pubkey" to blindedPkHex, + "X-FS-Timestamp" to timestamp.toString(), + "X-FS-Signature" to Base64.encodeToString(signature, Base64.NO_WRAP) + ) + ) + + // transform the promise into a coroutine + val result = send(request).await() + + // map out the result + return JsonUtil.fromJson(result, Map::class.java).let { + VersionData( + statusCode = it["status_code"] as? Int ?: 0, + version = it["result"] as? String ?: "", + updated = it["updated"] as? Double ?: 0.0 + ) + } + } } \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt b/libsession/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt new file mode 100644 index 0000000000..b7c28020e7 --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/file_server/VersionData.kt @@ -0,0 +1,7 @@ +package org.session.libsession.messaging.file_server + +data class VersionData( + val statusCode: Int, // The value 200. Included for backwards compatibility, and may be removed someday. + val version: String, // The Session version. + val updated: Double // The unix timestamp when this version was retrieved from Github; this can be up to 24 hours ago in case of consistent fetch errors, though normally will be within the last 30 minutes. +) \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt index 4a3299d197..a9f076b106 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/ConfigurationSyncJob.kt @@ -63,7 +63,6 @@ data class ConfigurationSyncJob(val destination: Destination): Job { // return a list of batch request objects val snodeMessage = MessageSender.buildConfigMessageToSnode(destination.destinationPublicKey(), message) val authenticated = SnodeAPI.buildAuthenticatedStoreBatchInfo( - destination.destinationPublicKey(), config.configNamespace(), snodeMessage ) ?: return@map null // this entry will be null otherwise diff --git a/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt index 54efa1c80b..d82f29446d 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/jobs/RetrieveProfileAvatarJob.kt @@ -12,11 +12,11 @@ import org.session.libsession.utilities.Util.equals import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.streams.ProfileCipherInputStream import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.Util.SECURE_RANDOM import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream -import java.security.SecureRandom import java.util.concurrent.ConcurrentSkipListSet class RetrieveProfileAvatarJob(private val profileAvatar: String?, val recipientAddress: Address): Job { @@ -64,7 +64,7 @@ class RetrieveProfileAvatarJob(private val profileAvatar: String?, val recipient Log.w(TAG, "Removing profile avatar for: " + recipient.address.serialize()) if (recipient.isLocalNumber) { - setProfileAvatarId(context, SecureRandom().nextInt()) + setProfileAvatarId(context, SECURE_RANDOM.nextInt()) setProfilePictureURL(context, null) } @@ -83,7 +83,7 @@ class RetrieveProfileAvatarJob(private val profileAvatar: String?, val recipient decryptDestination.renameTo(AvatarHelper.getAvatarFile(context, recipient.address)) if (recipient.isLocalNumber) { - setProfileAvatarId(context, SecureRandom().nextInt()) + setProfileAvatarId(context, SECURE_RANDOM.nextInt()) setProfilePictureURL(context, profileAvatar) } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt index 293fbc8d8a..c9b7abefbc 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/ClosedGroupPollerV2.kt @@ -10,7 +10,7 @@ import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.GroupUtil -import org.session.libsignal.crypto.getRandomElementOrNull +import org.session.libsignal.crypto.secureRandomOrNull import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.defaultRequiresAuth @@ -104,7 +104,7 @@ class ClosedGroupPollerV2 { fun poll(groupPublicKey: String): Promise { if (!isPolling(groupPublicKey)) { return Promise.of(Unit) } val promise = SnodeAPI.getSwarm(groupPublicKey).bind { swarm -> - val snode = swarm.getRandomElementOrNull() ?: throw InsufficientSnodesException() // Should be cryptographically secure + val snode = swarm.secureRandomOrNull() ?: throw InsufficientSnodesException() // Should be cryptographically secure if (!isPolling(groupPublicKey)) { throw PollingCanceledException() } val currentForkInfo = SnodeAPI.forkInfo when { diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 2b7c8159ea..41db054d59 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -19,8 +19,6 @@ import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.messages.control.SharedConfigurationMessage -import org.session.libsession.messaging.sending_receiving.MessageReceiver import org.session.libsession.snode.RawResponse import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeModule @@ -29,7 +27,7 @@ import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode -import java.security.SecureRandom +import org.session.libsignal.utilities.Util.SECURE_RANDOM import java.util.Timer import java.util.TimerTask import kotlin.time.Duration.Companion.days @@ -106,7 +104,7 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti val swarm = SnodeModule.shared.storage.getSwarm(userPublicKey) ?: setOf() val unusedSnodes = swarm.subtract(usedSnodes) if (unusedSnodes.isNotEmpty()) { - val index = SecureRandom().nextInt(unusedSnodes.size) + val index = SECURE_RANDOM.nextInt(unusedSnodes.size) val nextSnode = unusedSnodes.elementAt(index) usedSnodes.add(nextSnode) Log.d(TAG, "Polling $nextSnode.") @@ -142,8 +140,7 @@ class Poller(private val configFactory: ConfigFactoryProtocol, debounceTimer: Ti val messages = rawMessages["messages"] as? List<*> val processed = if (!messages.isNullOrEmpty()) { SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace) - SnodeAPI.removeDuplicates(userPublicKey, messages, namespace, true).mapNotNull { messageBody -> - val rawMessageAsJSON = messageBody as? Map<*, *> ?: return@mapNotNull null + SnodeAPI.removeDuplicates(userPublicKey, messages, namespace, true).mapNotNull { rawMessageAsJSON -> val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset diff --git a/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt b/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt index 569b9c62c1..71cd587b45 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt @@ -28,7 +28,7 @@ object MessageWrapper { val webSocketMessage = createWebSocketMessage(envelope) return webSocketMessage.toByteArray() } catch (e: Exception) { - throw if (e is Error) { e } else { Error.FailedToWrapData } + throw if (e is Error) e else Error.FailedToWrapData } } @@ -49,15 +49,15 @@ object MessageWrapper { private fun createWebSocketMessage(envelope: Envelope): WebSocketMessage { try { - val requestBuilder = WebSocketRequestMessage.newBuilder() - requestBuilder.verb = "PUT" - requestBuilder.path = "/api/v1/message" - requestBuilder.id = SecureRandom.getInstance("SHA1PRNG").nextLong() - requestBuilder.body = envelope.toByteString() - val messageBuilder = WebSocketMessage.newBuilder() - messageBuilder.request = requestBuilder.build() - messageBuilder.type = WebSocketMessage.Type.REQUEST - return messageBuilder.build() + return WebSocketMessage.newBuilder().apply { + request = WebSocketRequestMessage.newBuilder().apply { + verb = "PUT" + path = "/api/v1/message" + id = SecureRandom.getInstance("SHA1PRNG").nextLong() + body = envelope.toByteString() + }.build() + type = WebSocketMessage.Type.REQUEST + }.build() } catch (e: Exception) { Log.d("Loki", "Failed to wrap envelope in web socket message: ${e.message}.") throw Error.FailedToWrapEnvelopeInWebSocketMessage diff --git a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index ead454eb44..9ff541a9d5 100644 --- a/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -10,11 +10,10 @@ import okhttp3.Request import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.utilities.AESGCM import org.session.libsession.utilities.AESGCM.EncryptionResult -import org.session.libsession.utilities.Util import org.session.libsession.utilities.getBodyForOnionRequest import org.session.libsession.utilities.getHeadersForOnionRequest -import org.session.libsignal.crypto.getRandomElement -import org.session.libsignal.crypto.getRandomElementOrNull +import org.session.libsignal.crypto.secureRandom +import org.session.libsignal.crypto.secureRandomOrNull import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Broadcaster @@ -149,7 +148,7 @@ object OnionRequestAPI { val reusableGuardSnodeCount = reusableGuardSnodes.count() if (unusedSnodes.count() < (targetGuardSnodeCount - reusableGuardSnodeCount)) { throw InsufficientSnodesException() } fun getGuardSnode(): Promise { - val candidate = unusedSnodes.getRandomElementOrNull() + val candidate = unusedSnodes.secureRandomOrNull() ?: return Promise.ofFail(InsufficientSnodesException()) unusedSnodes = unusedSnodes.minus(candidate) Log.d("Loki", "Testing guard snode: $candidate.") @@ -191,7 +190,7 @@ object OnionRequestAPI { // Don't test path snodes as this would reveal the user's IP to them guardSnodes.minus(reusableGuardSnodes).map { guardSnode -> val result = listOf( guardSnode ) + (0 until (pathSize - 1)).mapIndexed() { index, _ -> - var pathSnode = unusedSnodes.getRandomElement() + var pathSnode = unusedSnodes.secureRandom() // remove the snode from the unused list and return it unusedSnodes = unusedSnodes.minus(pathSnode) @@ -228,9 +227,9 @@ object OnionRequestAPI { OnionRequestAPI.guardSnodes = guardSnodes fun getPath(paths: List): Path { return if (snodeToExclude != null) { - paths.filter { !it.contains(snodeToExclude) }.getRandomElement() + paths.filter { !it.contains(snodeToExclude) }.secureRandom() } else { - paths.getRandomElement() + paths.secureRandom() } } when { @@ -273,7 +272,7 @@ object OnionRequestAPI { path.removeAt(snodeIndex) val unusedSnodes = SnodeAPI.snodePool.minus(oldPaths.flatten()) if (unusedSnodes.isEmpty()) { throw InsufficientSnodesException() } - path.add(unusedSnodes.getRandomElement()) + path.add(unusedSnodes.secureRandom()) // Don't test the new snode as this would reveal the user's IP oldPaths.removeAt(pathIndex) val newPaths = oldPaths + listOf( path ) diff --git a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt index 034ef0e7d2..099b2541ab 100644 --- a/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -9,16 +9,21 @@ import com.goterl.lazysodium.interfaces.PwHash import com.goterl.lazysodium.interfaces.SecretBox import com.goterl.lazysodium.interfaces.Sign import com.goterl.lazysodium.utils.Key +import com.goterl.lazysodium.utils.KeyPair import nl.komponents.kovenant.Promise import nl.komponents.kovenant.all -import nl.komponents.kovenant.deferred import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.map import nl.komponents.kovenant.task +import nl.komponents.kovenant.unwrap import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.messaging.utilities.SodiumUtilities.sodium -import org.session.libsignal.crypto.getRandomElement +import org.session.libsession.utilities.buildMutableMap +import org.session.libsession.utilities.mapValuesNotNull +import org.session.libsession.utilities.toByteArray +import org.session.libsignal.crypto.secureRandom +import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.Base64 @@ -29,10 +34,8 @@ import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Namespace import org.session.libsignal.utilities.Snode -import org.session.libsignal.utilities.ThreadUtils import org.session.libsignal.utilities.prettifiedDescription import org.session.libsignal.utilities.retryIfNeeded -import java.security.SecureRandom import java.util.Locale import kotlin.collections.component1 import kotlin.collections.component2 @@ -45,7 +48,7 @@ object SnodeAPI { private val broadcaster: Broadcaster get() = SnodeModule.shared.broadcaster - internal var snodeFailureCount: MutableMap = mutableMapOf() + private var snodeFailureCount: MutableMap = mutableMapOf() internal var snodePool: Set get() = database.getSnodePool() set(newValue) { database.setSnodePool(newValue) } @@ -56,7 +59,7 @@ object SnodeAPI { internal var clockOffset = 0L @JvmStatic - public val nowWithOffset + val nowWithOffset get() = System.currentTimeMillis() + clockOffset internal var forkInfo by observable(database.getForkInfo()) { _, oldValue, newValue -> @@ -67,34 +70,30 @@ object SnodeAPI { } // Settings - private val maxRetryCount = 6 - private val minimumSnodePoolCount = 12 - private val minimumSwarmSnodeCount = 3 + private const val maxRetryCount = 6 + private const val minimumSnodePoolCount = 12 + private const val minimumSwarmSnodeCount = 3 // Use port 4433 if the API level can handle the network security configuration and enforce pinned certificates private val seedNodePort = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) 443 else 4443 - private val seedNodePool by lazy { - if (useTestnet) { - setOf( "http://public.loki.foundation:38157" ) - } else { - setOf( - "https://seed1.getsession.org:$seedNodePort", - "https://seed2.getsession.org:$seedNodePort", - "https://seed3.getsession.org:$seedNodePort", - ) - } - } + + private const val useTestnet = false + + private val seedNodePool = if (useTestnet) setOf( + "http://public.loki.foundation:38157" + ) else setOf( + "https://seed1.getsession.org:$seedNodePort", + "https://seed2.getsession.org:$seedNodePort", + "https://seed3.getsession.org:$seedNodePort", + ) + private const val snodeFailureThreshold = 3 private const val useOnionRequests = true - const val useTestnet = false - - const val KEY_IP = "public_ip" - const val KEY_PORT = "storage_port" - const val KEY_X25519 = "pubkey_x25519" - const val KEY_ED25519 = "pubkey_ed25519" - const val KEY_VERSION = "storage_server_version" - - const val EMPTY_VERSION = "0.0.0" + private const val KEY_IP = "public_ip" + private const val KEY_PORT = "storage_port" + private const val KEY_X25519 = "pubkey_x25519" + private const val KEY_ED25519 = "pubkey_ed25519" + private const val KEY_VERSION = "storage_server_version" // Error sealed class Error(val description: String) : Exception(description) { @@ -123,132 +122,88 @@ object SnodeAPI { parameters: Map, publicKey: String? = null, version: Version = Version.V3 - ): RawResponsePromise { - val url = "${snode.address}:${snode.port}/storage_rpc/v1" - val deferred = deferred, Exception>() - if (useOnionRequests) { - OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).map { - val body = it.body ?: throw Error.Generic - deferred.resolve(JsonUtil.fromJson(body, Map::class.java)) - }.fail { deferred.reject(it) } - } else { - ThreadUtils.queue { - val payload = mapOf( "method" to method.rawValue, "params" to parameters ) - try { - val response = HTTP.execute(HTTP.Verb.POST, url, payload).toString() - val json = JsonUtil.fromJson(response, Map::class.java) - deferred.resolve(json) - } catch (exception: Exception) { - val httpRequestFailedException = exception as? HTTP.HTTPRequestFailedException - if (httpRequestFailedException != null) { - val error = handleSnodeError(httpRequestFailedException.statusCode, httpRequestFailedException.json, snode, publicKey) - if (error != null) { return@queue deferred.reject(exception) } - } - Log.d("Loki", "Unhandled exception: $exception.") - deferred.reject(exception) + ): RawResponsePromise = when { + useOnionRequests -> OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).map { + JsonUtil.fromJson(it.body ?: throw Error.Generic, Map::class.java) + } + else -> task { + HTTP.execute( + HTTP.Verb.POST, + url = "${snode.address}:${snode.port}/storage_rpc/v1", + parameters = buildMap { + this["method"] = method.rawValue + this["params"] = parameters } + ).toString().let { + JsonUtil.fromJson(it, Map::class.java) + } + }.fail { e -> + when (e) { + is HTTP.HTTPRequestFailedException -> handleSnodeError(e.statusCode, e.json, snode, publicKey) + else -> Log.d("Loki", "Unhandled exception: $e.") } } - return deferred.promise } - internal fun getRandomSnode(): Promise { - val snodePool = this.snodePool + private val GET_RANDOM_SNODE_PARAMS = buildMap { + this["method"] = "get_n_service_nodes" + this["params"] = buildMap { + this["active_only"] = true + this["fields"] = sequenceOf(KEY_IP, KEY_PORT, KEY_X25519, KEY_ED25519, KEY_VERSION).associateWith { true } + } + } - if (snodePool.count() < minimumSnodePoolCount) { + internal fun getRandomSnode(): Promise = + snodePool.takeIf { it.size >= minimumSnodePoolCount }?.secureRandom()?.let { Promise.of(it) } ?: task { val target = seedNodePool.random() - val url = "$target/json_rpc" Log.d("Loki", "Populating snode pool using: $target.") - val parameters = mapOf( - "method" to "get_n_service_nodes", - "params" to mapOf( - "active_only" to true, - "fields" to mapOf( - KEY_IP to true, KEY_PORT to true, - KEY_X25519 to true, KEY_ED25519 to true, - KEY_VERSION to true - ) - ) - ) - val deferred = deferred() - deferred() - ThreadUtils.queue { - try { - val response = HTTP.execute(HTTP.Verb.POST, url, parameters, useSeedNodeConnection = true) - val json = try { - JsonUtil.fromJson(response, Map::class.java) - } catch (exception: Exception) { - mapOf( "result" to response.toString()) - } - val intermediate = json["result"] as? Map<*, *> - val rawSnodes = intermediate?.get("service_node_states") as? List<*> - if (rawSnodes != null) { - val snodePool = rawSnodes.mapNotNull { rawSnode -> - val rawSnodeAsJSON = rawSnode as? Map<*, *> - val address = rawSnodeAsJSON?.get(KEY_IP) as? String - val port = rawSnodeAsJSON?.get(KEY_PORT) as? Int - val ed25519Key = rawSnodeAsJSON?.get(KEY_ED25519) as? String - val x25519Key = rawSnodeAsJSON?.get(KEY_X25519) as? String - val version = (rawSnodeAsJSON?.get(KEY_VERSION) as? ArrayList<*>) - ?.filterIsInstance() // get the array as Integers - ?.joinToString(separator = ".") // turn it int a version string + val url = "$target/json_rpc" + val response = HTTP.execute(HTTP.Verb.POST, url, GET_RANDOM_SNODE_PARAMS, useSeedNodeConnection = true) + val json = runCatching { JsonUtil.fromJson(response, Map::class.java) }.getOrNull() + ?: buildMap { this["result"] = response.toString() } + val intermediate = json["result"] as? Map<*, *> ?: throw Error.Generic + .also { Log.d("Loki", "Failed to update snode pool, intermediate was null.") } + val rawSnodes = intermediate["service_node_states"] as? List<*> ?: throw Error.Generic + .also { Log.d("Loki", "Failed to update snode pool, rawSnodes was null.") } - if (address != null && port != null && ed25519Key != null && x25519Key != null - && address != "0.0.0.0" && version != null) { - Snode( - address = "https://$address", - port = port, - publicKeySet = Snode.KeySet(ed25519Key, x25519Key), - version = version - ) - } else { - Log.d("Loki", "Failed to parse: ${rawSnode?.prettifiedDescription()}.") - null - } - }.toMutableSet() - Log.d("Loki", "Persisting snode pool to database.") - this.snodePool = snodePool - try { - deferred.resolve(snodePool.getRandomElement()) - } catch (exception: Exception) { - Log.d("Loki", "Got an empty snode pool from: $target.") - deferred.reject(SnodeAPI.Error.Generic) - } - } else { - Log.d("Loki", "Failed to update snode pool from: ${(rawSnodes as List<*>?)?.prettifiedDescription()}.") - deferred.reject(SnodeAPI.Error.Generic) - } - } catch (exception: Exception) { - deferred.reject(exception) - } - } - return deferred.promise - } else { - return Promise.of(snodePool.getRandomElement()) + rawSnodes.asSequence().mapNotNull { it as? Map<*, *> }.mapNotNull { rawSnode -> + createSnode( + address = rawSnode[KEY_IP] as? String, + port = rawSnode[KEY_PORT] as? Int, + ed25519Key = rawSnode[KEY_ED25519] as? String, + x25519Key = rawSnode[KEY_X25519] as? String, + version = (rawSnode[KEY_VERSION] as? List<*>) + ?.filterIsInstance() + ?.let(Snode::Version) + ).also { if (it == null) Log.d("Loki", "Failed to parse: ${rawSnode.prettifiedDescription()}.") } + }.toSet().also { + Log.d("Loki", "Persisting snode pool to database.") + snodePool = it + }.takeUnless { it.isEmpty() }?.secureRandom() ?: throw SnodeAPI.Error.Generic } - } - private fun extractVersionString(jsonVersion: String): String{ - return jsonVersion.removeSurrounding("[", "]").split(", ").joinToString(separator = ".") + private fun createSnode(address: String?, port: Int?, ed25519Key: String?, x25519Key: String?, version: Snode.Version? = Snode.Version.ZERO): Snode? { + return Snode( + address?.takeUnless { it == "0.0.0.0" }?.let { "https://$it" } ?: return null, + port ?: return null, + Snode.KeySet(ed25519Key ?: return null, x25519Key ?: return null), + version ?: return null + ) } internal fun dropSnodeFromSwarmIfNeeded(snode: Snode, publicKey: String) { - val swarm = database.getSwarm(publicKey)?.toMutableSet() - if (swarm != null && swarm.contains(snode)) { - swarm.remove(snode) - database.setSwarm(publicKey, swarm) + database.getSwarm(publicKey)?.takeIf { snode in it }?.let { + database.setSwarm(publicKey, it - snode) } } internal fun getSingleTargetSnode(publicKey: String): Promise { - // SecureRandom() should be cryptographically secure - return getSwarm(publicKey).map { it.shuffled(SecureRandom()).random() } + // SecureRandom should be cryptographically secure + return getSwarm(publicKey).map { it.shuffledRandom().random() } } // Public API - fun getAccountID(onsName: String): Promise { - val deferred = deferred() - val promise = deferred.promise + fun getAccountID(onsName: String): Promise = task { val validationCount = 3 val accountIDByteCount = 33 // Hash the ONS name using BLAKE2b @@ -256,181 +211,144 @@ object SnodeAPI { val nameAsData = onsName.toByteArray() val nameHash = ByteArray(GenericHash.BYTES) if (!sodium.cryptoGenericHash(nameHash, nameHash.size, nameAsData, nameAsData.size.toLong())) { - deferred.reject(Error.HashingFailed) - return promise + throw Error.HashingFailed } val base64EncodedNameHash = Base64.encodeBytes(nameHash) // Ask 3 different snodes for the Account ID associated with the given name hash - val parameters = mapOf( - "endpoint" to "ons_resolve", - "params" to mapOf( "type" to 0, "name_hash" to base64EncodedNameHash ) - ) - val promises = (1..validationCount).map { + val parameters = buildMap { + this["endpoint"] = "ons_resolve" + this["params"] = buildMap { + this["type"] = 0 + this["name_hash"] = base64EncodedNameHash + } + } + val promises = List(validationCount) { getRandomSnode().bind { snode -> retryIfNeeded(maxRetryCount) { invoke(Snode.Method.OxenDaemonRPCCall, snode, parameters) } } } - all(promises).success { results -> - val accountIDs = mutableListOf() - for (json in results) { - val intermediate = json["result"] as? Map<*, *> - val hexEncodedCiphertext = intermediate?.get("encrypted_value") as? String - if (hexEncodedCiphertext != null) { - val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) - val isArgon2Based = (intermediate["nonce"] == null) - if (isArgon2Based) { - // Handle old Argon2-based encryption used before HF16 - val salt = ByteArray(PwHash.SALTBYTES) - val key: ByteArray - val nonce = ByteArray(SecretBox.NONCEBYTES) - val accountIDAsData = ByteArray(accountIDByteCount) - try { - key = Key.fromHexString(sodium.cryptoPwHash(onsName, SecretBox.KEYBYTES, salt, PwHash.OPSLIMIT_MODERATE, PwHash.MEMLIMIT_MODERATE, PwHash.Alg.PWHASH_ALG_ARGON2ID13)).asBytes - } catch (e: SodiumException) { - deferred.reject(Error.HashingFailed) - return@success - } - if (!sodium.cryptoSecretBoxOpenEasy(accountIDAsData, ciphertext, ciphertext.size.toLong(), nonce, key)) { - deferred.reject(Error.DecryptionFailed) - return@success - } - accountIDs.add(Hex.toStringCondensed(accountIDAsData)) - } else { - val hexEncodedNonce = intermediate["nonce"] as? String - if (hexEncodedNonce == null) { - deferred.reject(Error.Generic) - return@success - } - val nonce = Hex.fromStringCondensed(hexEncodedNonce) - val key = ByteArray(GenericHash.BYTES) - if (!sodium.cryptoGenericHash(key, key.size, nameAsData, nameAsData.size.toLong(), nameHash, nameHash.size)) { - deferred.reject(Error.HashingFailed) - return@success - } - val accountIDAsData = ByteArray(accountIDByteCount) - if (!sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(accountIDAsData, null, null, ciphertext, ciphertext.size.toLong(), null, 0, nonce, key)) { - deferred.reject(Error.DecryptionFailed) - return@success - } - accountIDs.add(Hex.toStringCondensed(accountIDAsData)) + all(promises).map { results -> + results.map { json -> + val intermediate = json["result"] as? Map<*, *> ?: throw Error.Generic + val hexEncodedCiphertext = intermediate["encrypted_value"] as? String ?: throw Error.Generic + val ciphertext = Hex.fromStringCondensed(hexEncodedCiphertext) + val isArgon2Based = (intermediate["nonce"] == null) + if (isArgon2Based) { + // Handle old Argon2-based encryption used before HF16 + val salt = ByteArray(PwHash.SALTBYTES) + val nonce = ByteArray(SecretBox.NONCEBYTES) + val accountIDAsData = ByteArray(accountIDByteCount) + val key = try { + Key.fromHexString(sodium.cryptoPwHash(onsName, SecretBox.KEYBYTES, salt, PwHash.OPSLIMIT_MODERATE, PwHash.MEMLIMIT_MODERATE, PwHash.Alg.PWHASH_ALG_ARGON2ID13)).asBytes + } catch (e: SodiumException) { + throw Error.HashingFailed } + if (!sodium.cryptoSecretBoxOpenEasy(accountIDAsData, ciphertext, ciphertext.size.toLong(), nonce, key)) { + throw Error.DecryptionFailed + } + Hex.toStringCondensed(accountIDAsData) } else { - deferred.reject(Error.Generic) - return@success + val hexEncodedNonce = intermediate["nonce"] as? String ?: throw Error.Generic + val nonce = Hex.fromStringCondensed(hexEncodedNonce) + val key = ByteArray(GenericHash.BYTES) + if (!sodium.cryptoGenericHash(key, key.size, nameAsData, nameAsData.size.toLong(), nameHash, nameHash.size)) { + throw Error.HashingFailed + } + val accountIDAsData = ByteArray(accountIDByteCount) + if (!sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(accountIDAsData, null, null, ciphertext, ciphertext.size.toLong(), null, 0, nonce, key)) { + throw Error.DecryptionFailed + } + Hex.toStringCondensed(accountIDAsData) } - } - if (accountIDs.size == validationCount && accountIDs.toSet().size == 1) { - deferred.resolve(accountIDs.first()) - } else { - deferred.reject(Error.ValidationFailed) - } + }.takeIf { it.size == validationCount && it.toSet().size == 1 }?.first() + ?: throw Error.ValidationFailed } - return promise - } + }.unwrap() - fun getSwarm(publicKey: String): Promise, Exception> { - val cachedSwarm = database.getSwarm(publicKey) - return if (cachedSwarm != null && cachedSwarm.size >= minimumSwarmSnodeCount) { - val cachedSwarmCopy = mutableSetOf() // Workaround for a Kotlin compiler issue - cachedSwarmCopy.addAll(cachedSwarm) - task { cachedSwarmCopy } - } else { - val parameters = mapOf( "pubKey" to publicKey ) - getRandomSnode().bind { - invoke(Snode.Method.GetSwarm, it, parameters, publicKey) + fun getSwarm(publicKey: String): Promise, Exception> = + database.getSwarm(publicKey)?.takeIf { it.size >= minimumSwarmSnodeCount }?.let(Promise.Companion::of) + ?: getRandomSnode().bind { + invoke(Snode.Method.GetSwarm, it, parameters = buildMap { this["pubKey"] = publicKey }, publicKey) }.map { parseSnodes(it).toSet() }.success { database.setSwarm(publicKey, it) } - } + + private fun signAndEncodeCatching(data: ByteArray, userED25519KeyPair: KeyPair): Result = + runCatching { signAndEncode(data, userED25519KeyPair) } + private fun signAndEncode(data: ByteArray, userED25519KeyPair: KeyPair): String = + sign(data, userED25519KeyPair).let(Base64::encodeBytes) + private fun sign(data: ByteArray, userED25519KeyPair: KeyPair): ByteArray = ByteArray(Sign.BYTES).also { + sodium.cryptoSignDetached(it, data, data.size.toLong(), userED25519KeyPair.secretKey.asBytes) } + private fun getUserED25519KeyPairCatchingOrNull() = runCatching { MessagingModuleConfiguration.shared.getUserED25519KeyPair() }.getOrNull() + private fun getUserED25519KeyPair(): KeyPair? = MessagingModuleConfiguration.shared.getUserED25519KeyPair() + private fun getUserPublicKey() = MessagingModuleConfiguration.shared.storage.getUserPublicKey() + fun getRawMessages(snode: Snode, publicKey: String, requiresAuth: Boolean = true, namespace: Int = 0): RawResponsePromise { // Get last message hash val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" - val parameters = mutableMapOf( - "pubKey" to publicKey, - "last_hash" to lastHashValue, - ) + val parameters = buildMutableMap { + this["pubKey"] = publicKey + this["last_hash"] = lastHashValue + // If the namespace is default (0) here it will be implicitly read as 0 on the storage server + // we only need to specify it explicitly if we want to (in future) or if it is non-zero + namespace.takeIf { it != 0 }?.let { this["namespace"] = it } + } // Construct signature if (requiresAuth) { val userED25519KeyPair = try { - MessagingModuleConfiguration.shared.getUserED25519KeyPair() - ?: return Promise.ofFail(Error.NoKeyPair) + getUserED25519KeyPair() ?: return Promise.ofFail(Error.NoKeyPair) } catch (e: Exception) { Log.e("Loki", "Error getting KeyPair", e) return Promise.ofFail(Error.NoKeyPair) } val timestamp = System.currentTimeMillis() + clockOffset val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString - val signature = ByteArray(Sign.BYTES) - val verificationData = - if (namespace != 0) "retrieve$namespace$timestamp".toByteArray() - else "retrieve$timestamp".toByteArray() - try { - sodium.cryptoSignDetached( - signature, - verificationData, - verificationData.size.toLong(), - userED25519KeyPair.secretKey.asBytes - ) - } catch (exception: Exception) { - return Promise.ofFail(Error.SigningFailed) - } + val verificationData = buildString { + append("retrieve") + namespace.takeIf { it != 0 }?.let(::append) + append(timestamp) + }.toByteArray() + parameters["signature"] = signAndEncodeCatching(verificationData, userED25519KeyPair).getOrNull() + ?: return Promise.ofFail(Error.SigningFailed) parameters["timestamp"] = timestamp parameters["pubkey_ed25519"] = ed25519PublicKey - parameters["signature"] = Base64.encodeBytes(signature) - } - - // If the namespace is default (0) here it will be implicitly read as 0 on the storage server - // we only need to specify it explicitly if we want to (in future) or if it is non-zero - if (namespace != 0) { - parameters["namespace"] = namespace } // Make the request return invoke(Snode.Method.Retrieve, snode, parameters, publicKey) } - fun buildAuthenticatedStoreBatchInfo(publicKey: String, namespace: Int, message: SnodeMessage): SnodeBatchRequestInfo? { - val params = mutableMapOf() - // load the message data params into the sub request - // currently loads: - // pubKey - // data - // ttl - // timestamp - params.putAll(message.toJSON()) - params["namespace"] = namespace - + fun buildAuthenticatedStoreBatchInfo(namespace: Int, message: SnodeMessage): SnodeBatchRequestInfo? { // used for sig generation since it is also the value used in timestamp parameter val messageTimestamp = message.timestamp - val userEd25519KeyPair = try { - MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null - } catch (e: Exception) { - return null + val userED25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return null + + val verificationData = "store$namespace$messageTimestamp".toByteArray() + val signature = signAndEncodeCatching(verificationData, userED25519KeyPair).run { + getOrNull() ?: return null.also { Log.e("Loki", "Signing data failed with user secret key", exceptionOrNull()) } } - val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString - val signature = ByteArray(Sign.BYTES) - val verificationData = "store$namespace$messageTimestamp".toByteArray() - try { - sodium.cryptoSignDetached( - signature, - verificationData, - verificationData.size.toLong(), - userEd25519KeyPair.secretKey.asBytes - ) - } catch (e: Exception) { - Log.e("Loki", "Signing data failed with user secret key", e) + val params = buildMap { + // load the message data params into the sub request + // currently loads: + // pubKey + // data + // ttl + // timestamp + putAll(message.toJSON()) + this["namespace"] = namespace + // timestamp already set + this["pubkey_ed25519"] = userED25519KeyPair.publicKey.asHexString + this["signature"] = signature } - // timestamp already set - params["pubkey_ed25519"] = ed25519PublicKey - params["signature"] = Base64.encodeBytes(signature) + return SnodeBatchRequestInfo( Snode.Method.SendMessage.rawValue, params, @@ -445,32 +363,22 @@ object SnodeAPI { * @param required indicates that *at least one* message in the list is deleted from the server, otherwise it will return 404 */ fun buildAuthenticatedDeleteBatchInfo(publicKey: String, messageHashes: List, required: Boolean = false): SnodeBatchRequestInfo? { - val params = mutableMapOf( - "pubkey" to publicKey, - "required" to required, // could be omitted technically but explicit here - "messages" to messageHashes - ) - val userEd25519KeyPair = try { - MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null - } catch (e: Exception) { - return null - } + val userEd25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return null val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString - val signature = ByteArray(Sign.BYTES) - val verificationData = "delete${messageHashes.joinToString("")}".toByteArray() - try { - sodium.cryptoSignDetached( - signature, - verificationData, - verificationData.size.toLong(), - userEd25519KeyPair.secretKey.asBytes - ) + val verificationData = sequenceOf("delete").plus(messageHashes).toByteArray() + val signature = try { + signAndEncode(verificationData, userEd25519KeyPair) } catch (e: Exception) { Log.e("Loki", "Signing data failed with user secret key", e) return null } - params["pubkey_ed25519"] = ed25519PublicKey - params["signature"] = Base64.encodeBytes(signature) + val params = buildMap { + this["pubkey"] = publicKey + this["required"] = required // could be omitted technically but explicit here + this["messages"] = messageHashes + this["pubkey_ed25519"] = ed25519PublicKey + this["signature"] = signature + } return SnodeBatchRequestInfo( Snode.Method.DeleteMessage.rawValue, params, @@ -480,39 +388,25 @@ object SnodeAPI { fun buildAuthenticatedRetrieveBatchRequest(snode: Snode, publicKey: String, namespace: Int = 0, maxSize: Int? = null): SnodeBatchRequestInfo? { val lastHashValue = database.getLastMessageHashValue(snode, publicKey, namespace) ?: "" - val params = mutableMapOf( - "pubkey" to publicKey, - "last_hash" to lastHashValue, - ) - val userEd25519KeyPair = try { - MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null - } catch (e: Exception) { - return null - } + val userEd25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return null val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString val timestamp = System.currentTimeMillis() + clockOffset - val signature = ByteArray(Sign.BYTES) val verificationData = if (namespace == 0) "retrieve$timestamp".toByteArray() else "retrieve$namespace$timestamp".toByteArray() - try { - sodium.cryptoSignDetached( - signature, - verificationData, - verificationData.size.toLong(), - userEd25519KeyPair.secretKey.asBytes - ) + val signature = try { + signAndEncode(verificationData, userEd25519KeyPair) } catch (e: Exception) { Log.e("Loki", "Signing data failed with user secret key", e) return null } - params["timestamp"] = timestamp - params["pubkey_ed25519"] = ed25519PublicKey - params["signature"] = Base64.encodeBytes(signature) - if (namespace != 0) { - params["namespace"] = namespace - } - if (maxSize != null) { - params["max_size"] = maxSize + val params = buildMap { + this["pubkey"] = publicKey + this["last_hash"] = lastHashValue + this["timestamp"] = timestamp + this["pubkey_ed25519"] = ed25519PublicKey + this["signature"] = signature + if (namespace != 0) this["namespace"] = namespace + if (maxSize != null) this["max_size"] = maxSize } return SnodeBatchRequestInfo( Snode.Method.Retrieve.rawValue, @@ -536,13 +430,12 @@ object SnodeAPI { } fun getRawBatchResponse(snode: Snode, publicKey: String, requests: List, sequence: Boolean = false): RawResponsePromise { - val parameters = mutableMapOf( - "requests" to requests - ) + val parameters = buildMap { this["requests"] = requests } return invoke(if (sequence) Snode.Method.Sequence else Snode.Method.Batch, snode, parameters, publicKey).success { rawResponses -> - val responseList = (rawResponses["results"] as List) - responseList.forEachIndexed { index, response -> - if (response["code"] as? Int != 200) { + rawResponses["results"].let { it as List } + .asSequence() + .filter { it["code"] as? Int != 200 } + .forEach { response -> Log.w("Loki", "response code was not 200") handleSnodeError( response["code"] as? Int ?: 0, @@ -551,45 +444,38 @@ object SnodeAPI { publicKey ) } - } } } fun getExpiries(messageHashes: List, publicKey: String) : RawResponsePromise { - val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return Promise.ofFail(NullPointerException("No user key pair")) + val userEd25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return Promise.ofFail(NullPointerException("No user key pair")) val hashes = messageHashes.takeIf { it.size != 1 } ?: (messageHashes + "///////////////////////////////////////////") // TODO remove this when bug is fixed on nodes. return retryIfNeeded(maxRetryCount) { val timestamp = System.currentTimeMillis() + clockOffset - val signData = "${Snode.Method.GetExpiries.rawValue}$timestamp${hashes.joinToString(separator = "")}".toByteArray() + val signData = sequenceOf(Snode.Method.GetExpiries.rawValue).plus(timestamp.toString()).plus(hashes).toByteArray() val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString - val signature = ByteArray(Sign.BYTES) - try { - sodium.cryptoSignDetached( - signature, - signData, - signData.size.toLong(), - userEd25519KeyPair.secretKey.asBytes - ) + val signature = try { + signAndEncode(signData, userEd25519KeyPair) } catch (e: Exception) { Log.e("Loki", "Signing data failed with user secret key", e) return@retryIfNeeded Promise.ofFail(e) } - val params = mapOf( - "pubkey" to publicKey, - "messages" to hashes, - "timestamp" to timestamp, - "pubkey_ed25519" to ed25519PublicKey, - "signature" to Base64.encodeBytes(signature) - ) + val params = buildMap { + this["pubkey"] = publicKey + this["messages"] = hashes + this["timestamp"] = timestamp + this["pubkey_ed25519"] = ed25519PublicKey + this["signature"] = signature + } getSingleTargetSnode(publicKey) bind { snode -> invoke(Snode.Method.GetExpiries, snode, params, publicKey) } } } - fun alterTtl(messageHashes: List, newExpiry: Long, publicKey: String, extend: Boolean = false, shorten: Boolean = false): RawResponsePromise { - return retryIfNeeded(maxRetryCount) { + fun alterTtl(messageHashes: List, newExpiry: Long, publicKey: String, extend: Boolean = false, shorten: Boolean = false): RawResponsePromise = + retryIfNeeded(maxRetryCount) { val params = buildAlterTtlParams(messageHashes, newExpiry, publicKey, extend, shorten) ?: return@retryIfNeeded Promise.ofFail( Exception("Couldn't build signed params for alterTtl request for newExpiry=$newExpiry, extend=$extend, shorten=$shorten") @@ -598,323 +484,218 @@ object SnodeAPI { invoke(Snode.Method.Expire, snode, params, publicKey) } } - } private fun buildAlterTtlParams( // TODO: in future this will probably need to use the closed group subkeys / admin keys for group swarms messageHashes: List, newExpiry: Long, publicKey: String, extend: Boolean = false, - shorten: Boolean = false): Map? { - val userEd25519KeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair() ?: return null - val params = mutableMapOf( - "expiry" to newExpiry, - "messages" to messageHashes, - ) - if (extend) { - params["extend"] = true - } else if (shorten) { - params["shorten"] = true - } + shorten: Boolean = false + ): Map? { + val userEd25519KeyPair = getUserED25519KeyPairCatchingOrNull() ?: return null + val shortenOrExtend = if (extend) "extend" else if (shorten) "shorten" else "" - val signData = "${Snode.Method.Expire.rawValue}$shortenOrExtend$newExpiry${messageHashes.joinToString(separator = "")}".toByteArray() + val signData = sequenceOf(Snode.Method.Expire.rawValue).plus(shortenOrExtend).plus(newExpiry.toString()).plus(messageHashes).toByteArray() - val ed25519PublicKey = userEd25519KeyPair.publicKey.asHexString - val signature = ByteArray(Sign.BYTES) - try { - sodium.cryptoSignDetached( - signature, - signData, - signData.size.toLong(), - userEd25519KeyPair.secretKey.asBytes - ) + val signature = try { + signAndEncode(signData, userEd25519KeyPair) } catch (e: Exception) { Log.e("Loki", "Signing data failed with user secret key", e) return null } - params["pubkey"] = publicKey - params["pubkey_ed25519"] = ed25519PublicKey - params["signature"] = Base64.encodeBytes(signature) - return params - } - - fun getMessages(publicKey: String): MessageListPromise { - return retryIfNeeded(maxRetryCount) { - getSingleTargetSnode(publicKey).bind { snode -> - getRawMessages(snode, publicKey).map { parseRawMessagesResponse(it, snode, publicKey) } + return buildMap { + this["expiry"] = newExpiry + this["messages"] = messageHashes + when { + extend -> this["extend"] = true + shorten -> this["shorten"] = true } + this["pubkey"] = publicKey + this["pubkey_ed25519"] = userEd25519KeyPair.publicKey.asHexString + this["signature"] = signature } } - private fun getNetworkTime(snode: Snode): Promise, Exception> { - return invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse -> + fun getMessages(publicKey: String): MessageListPromise = retryIfNeeded(maxRetryCount) { + getSingleTargetSnode(publicKey).bind { snode -> + getRawMessages(snode, publicKey).map { parseRawMessagesResponse(it, snode, publicKey) } + } + } + + private fun getNetworkTime(snode: Snode): Promise, Exception> = + invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse -> val timestamp = rawResponse["timestamp"] as? Long ?: -1 snode to timestamp } - } - fun sendMessage(message: SnodeMessage, requiresAuth: Boolean = false, namespace: Int = 0): RawResponsePromise { - val destination = message.recipient - return retryIfNeeded(maxRetryCount) { - val module = MessagingModuleConfiguration.shared - val userED25519KeyPair = module.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) - val parameters = message.toJSON().toMutableMap() + fun sendMessage(message: SnodeMessage, requiresAuth: Boolean = false, namespace: Int = 0): RawResponsePromise = + retryIfNeeded(maxRetryCount) { + val userED25519KeyPair = getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) + val parameters = message.toJSON().toMutableMap() // Construct signature if (requiresAuth) { val sigTimestamp = nowWithOffset val ed25519PublicKey = userED25519KeyPair.publicKey.asHexString - val signature = ByteArray(Sign.BYTES) // assume namespace here is non-zero, as zero namespace doesn't require auth val verificationData = "store$namespace$sigTimestamp".toByteArray() - try { - sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) + val signature = try { + signAndEncode(verificationData, userED25519KeyPair) } catch (exception: Exception) { return@retryIfNeeded Promise.ofFail(Error.SigningFailed) } parameters["sig_timestamp"] = sigTimestamp parameters["pubkey_ed25519"] = ed25519PublicKey - parameters["signature"] = Base64.encodeBytes(signature) + parameters["signature"] = signature } // If the namespace is default (0) here it will be implicitly read as 0 on the storage server // we only need to specify it explicitly if we want to (in future) or if it is non-zero if (namespace != 0) { parameters["namespace"] = namespace } + val destination = message.recipient getSingleTargetSnode(destination).bind { snode -> invoke(Snode.Method.SendMessage, snode, parameters, destination) } } - } - fun deleteMessage(publicKey: String, serverHashes: List): Promise, Exception> { - return retryIfNeeded(maxRetryCount) { - val module = MessagingModuleConfiguration.shared - val userED25519KeyPair = module.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) - val userPublicKey = module.storage.getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) + fun deleteMessage(publicKey: String, serverHashes: List): Promise, Exception> = + retryIfNeeded(maxRetryCount) { + val userED25519KeyPair = getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) + val userPublicKey = getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) getSingleTargetSnode(publicKey).bind { snode -> retryIfNeeded(maxRetryCount) { - val signature = ByteArray(Sign.BYTES) - val verificationData = (Snode.Method.DeleteMessage.rawValue + serverHashes.fold("") { a, v -> a + v }).toByteArray() - sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) - val deleteMessageParams = mapOf( - "pubkey" to userPublicKey, - "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, - "messages" to serverHashes, - "signature" to Base64.encodeBytes(signature) - ) + val verificationData = sequenceOf(Snode.Method.DeleteMessage.rawValue).plus(serverHashes).toByteArray() + val deleteMessageParams = buildMap { + this["pubkey"] = userPublicKey + this["pubkey_ed25519"] = userED25519KeyPair.publicKey.asHexString + this["messages"] = serverHashes + this["signature"] = signAndEncode(verificationData, userED25519KeyPair) + } invoke(Snode.Method.DeleteMessage, snode, deleteMessageParams, publicKey).map { rawResponse -> val swarms = rawResponse["swarm"] as? Map ?: return@map mapOf() - val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) -> - val json = rawJSON as? Map ?: return@mapNotNull null - val isFailed = json["failed"] as? Boolean ?: false - val statusCode = json["code"] as? String - val reason = json["reason"] as? String - hexSnodePublicKey to if (isFailed) { - Log.e("Loki", "Failed to delete messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") - false - } else { - val hashes = json["deleted"] as List // Hashes of deleted messages - val signature = json["signature"] as String - val snodePublicKey = Key.fromHexString(hexSnodePublicKey) - // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - val message = (userPublicKey + serverHashes.fold("") { a, v -> a + v } + hashes.fold("") { a, v -> a + v }).toByteArray() - sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes) + swarms.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> + (rawJSON as? Map)?.let { json -> + val isFailed = json["failed"] as? Boolean ?: false + val statusCode = json["code"] as? String + val reason = json["reason"] as? String + + if (isFailed) { + Log.e("Loki", "Failed to delete messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") + false + } else { + // Hashes of deleted messages + val hashes = json["deleted"] as List + val signature = json["signature"] as String + val snodePublicKey = Key.fromHexString(hexSnodePublicKey) + // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) + val message = sequenceOf(userPublicKey).plus(serverHashes).plus(hashes).toByteArray() + sodium.cryptoSignVerifyDetached( + Base64.decode(signature), + message, + message.size, + snodePublicKey.asBytes + ) + } } } - return@map result.toMap() - }.fail { e -> - Log.e("Loki", "Failed to delete messages", e) - } + }.fail { e -> Log.e("Loki", "Failed to delete messages", e) } } } } - } // Parsing - private fun parseSnodes(rawResponse: Any): List { - val json = rawResponse as? Map<*, *> - val rawSnodes = json?.get("snodes") as? List<*> - if (rawSnodes != null) { - return rawSnodes.mapNotNull { rawSnode -> - val rawSnodeAsJSON = rawSnode as? Map<*, *> - val address = rawSnodeAsJSON?.get("ip") as? String - val portAsString = rawSnodeAsJSON?.get("port") as? String - val port = portAsString?.toInt() - val ed25519Key = rawSnodeAsJSON?.get(KEY_ED25519) as? String - val x25519Key = rawSnodeAsJSON?.get(KEY_X25519) as? String + private fun parseSnodes(rawResponse: Any): List = + (rawResponse as? Map<*, *>) + ?.run { get("snodes") as? List<*> } + ?.asSequence() + ?.mapNotNull { it as? Map<*, *> } + ?.mapNotNull { + createSnode( + address = it["ip"] as? String, + port = (it["port"] as? String)?.toInt(), + ed25519Key = it[KEY_ED25519] as? String, + x25519Key = it[KEY_X25519] as? String + ).apply { if (this == null) Log.d("Loki", "Failed to parse snode from: ${it.prettifiedDescription()}.") } + }?.toList() ?: listOf().also { Log.d("Loki", "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}.") } - if (address != null && port != null && ed25519Key != null && x25519Key != null && address != "0.0.0.0") { - Snode("https://$address", port, Snode.KeySet(ed25519Key, x25519Key), EMPTY_VERSION) - } else { - Log.d("Loki", "Failed to parse snode from: ${rawSnode?.prettifiedDescription()}.") - null - } - } - } else { - Log.d("Loki", "Failed to parse snodes from: ${rawResponse.prettifiedDescription()}.") - return listOf() - } - } - - fun deleteAllMessages(): Promise, Exception> { - return retryIfNeeded(maxRetryCount) { - val module = MessagingModuleConfiguration.shared - val userED25519KeyPair = module.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) - val userPublicKey = module.storage.getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) + fun deleteAllMessages(): Promise, Exception> = + retryIfNeeded(maxRetryCount) { + val userED25519KeyPair = getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) + val userPublicKey = getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) getSingleTargetSnode(userPublicKey).bind { snode -> retryIfNeeded(maxRetryCount) { getNetworkTime(snode).bind { (_, timestamp) -> - val signature = ByteArray(Sign.BYTES) - val verificationData = (Snode.Method.DeleteAll.rawValue + Namespace.ALL + timestamp.toString()).toByteArray() - sodium.cryptoSignDetached(signature, verificationData, verificationData.size.toLong(), userED25519KeyPair.secretKey.asBytes) - val deleteMessageParams = mapOf( - "pubkey" to userPublicKey, - "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, - "timestamp" to timestamp, - "signature" to Base64.encodeBytes(signature), - "namespace" to Namespace.ALL, - ) - invoke(Snode.Method.DeleteAll, snode, deleteMessageParams, userPublicKey).map { - rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse) - }.fail { e -> - Log.e("Loki", "Failed to clear data", e) + val verificationData = sequenceOf(Snode.Method.DeleteAll.rawValue, Namespace.ALL, timestamp.toString()).toByteArray() + val deleteMessageParams = buildMap { + this["pubkey"] = userPublicKey + this["pubkey_ed25519"] = userED25519KeyPair.publicKey.asHexString + this["timestamp"] = timestamp + this["signature"] = signAndEncode(verificationData, userED25519KeyPair) + this["namespace"] = Namespace.ALL } + invoke(Snode.Method.DeleteAll, snode, deleteMessageParams, userPublicKey) + .map { rawResponse -> parseDeletions(userPublicKey, timestamp, rawResponse) } + .fail { e -> Log.e("Loki", "Failed to clear data", e) } } } } } - } - fun updateExpiry(updatedExpiryMs: Long, serverHashes: List): Promise, Long>>, Exception> { - return retryIfNeeded(maxRetryCount) { - val module = MessagingModuleConfiguration.shared - val userED25519KeyPair = module.getUserED25519KeyPair() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) - val userPublicKey = module.storage.getUserPublicKey() ?: return@retryIfNeeded Promise.ofFail(Error.NoKeyPair) - val updatedExpiryMsWithNetworkOffset = updatedExpiryMs + clockOffset - getSingleTargetSnode(userPublicKey).bind { snode -> - retryIfNeeded(maxRetryCount) { - // "expire" || expiry || messages[0] || ... || messages[N] - val verificationData = - (Snode.Method.Expire.rawValue + updatedExpiryMsWithNetworkOffset + serverHashes.fold("") { a, v -> a + v }).toByteArray() - val signature = ByteArray(Sign.BYTES) - sodium.cryptoSignDetached( - signature, - verificationData, - verificationData.size.toLong(), - userED25519KeyPair.secretKey.asBytes - ) - val params = mapOf( - "pubkey" to userPublicKey, - "pubkey_ed25519" to userED25519KeyPair.publicKey.asHexString, - "expiry" to updatedExpiryMs, - "messages" to serverHashes, - "signature" to Base64.encodeBytes(signature) - ) - invoke(Snode.Method.Expire, snode, params, userPublicKey).map { rawResponse -> - val swarms = rawResponse["swarm"] as? Map ?: return@map mapOf() - val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) -> - val json = rawJSON as? Map ?: return@mapNotNull null - val isFailed = json["failed"] as? Boolean ?: false - val statusCode = json["code"] as? String - val reason = json["reason"] as? String - hexSnodePublicKey to if (isFailed) { - Log.e("Loki", "Failed to update expiry for: $hexSnodePublicKey due to error: $reason ($statusCode).") - listOf() to 0L - } else { - val hashes = json["updated"] as List - val expiryApplied = json["expiry"] as Long - val signature = json["signature"] as String - val snodePublicKey = Key.fromHexString(hexSnodePublicKey) - // The signature looks like ( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] ) - val message = (userPublicKey + serverHashes.fold("") { a, v -> a + v } + hashes.fold("") { a, v -> a + v }).toByteArray() - if (sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes)) { - hashes to expiryApplied - } else listOf() to 0L - } - } - return@map result.toMap() - }.fail { e -> - Log.e("Loki", "Failed to update expiry", e) - } - } - } - } - } - - fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true): List> { - val messages = rawResponse["messages"] as? List<*> - return if (messages != null) { - if (updateLatestHash) { - updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) - } - val newRawMessages = removeDuplicates(publicKey, messages, namespace, updateStoredHashes) - return parseEnvelopes(newRawMessages) - } else { - listOf() - } - } + fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true): List> = + (rawResponse["messages"] as? List<*>)?.let { messages -> + if (updateLatestHash) updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) + removeDuplicates(publicKey, messages, namespace, updateStoredHashes).let(::parseEnvelopes) + } ?: listOf() fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *> val hashValue = lastMessageAsJSON?.get("hash") as? String - if (hashValue != null) { - database.setLastMessageHashValue(snode, publicKey, hashValue, namespace) - } else if (rawMessages.isNotEmpty()) { - Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.") + when { + hashValue != null -> database.setLastMessageHashValue(snode, publicKey, hashValue, namespace) + rawMessages.isNotEmpty() -> Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.") } } - fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List<*> { - val originalMessageHashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() - val receivedMessageHashValues = originalMessageHashValues.toMutableSet() - val result = rawMessages.filter { rawMessage -> - val rawMessageAsJSON = rawMessage as? Map<*, *> - val hashValue = rawMessageAsJSON?.get("hash") as? String - if (hashValue != null) { - val isDuplicate = receivedMessageHashValues.contains(hashValue) - receivedMessageHashValues.add(hashValue) - !isDuplicate - } else { - Log.d("Loki", "Missing hash value for message: ${rawMessage?.prettifiedDescription()}.") - false + /** + * + * + * TODO Use a db transaction, synchronizing is sufficient for now because + * database#setReceivedMessageHashValues is only called here. + */ + @Synchronized + fun removeDuplicates(publicKey: String, rawMessages: List<*>, namespace: Int, updateStoredHashes: Boolean): List> { + val hashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() + return rawMessages.filterIsInstance>().filter { rawMessage -> + val hash = rawMessage["hash"] as? String + hash ?: Log.d("Loki", "Missing hash value for message: ${rawMessage.prettifiedDescription()}.") + hash?.let(hashValues::add) == true + }.also { + if (updateStoredHashes && it.isNotEmpty()) { + database.setReceivedMessageHashValues(publicKey, hashValues, namespace) } } - if (originalMessageHashValues != receivedMessageHashValues && updateStoredHashes) { - database.setReceivedMessageHashValues(publicKey, receivedMessageHashValues, namespace) - } - return result } - private fun parseEnvelopes(rawMessages: List<*>): List> { - return rawMessages.mapNotNull { rawMessage -> - val rawMessageAsJSON = rawMessage as? Map<*, *> - val base64EncodedData = rawMessageAsJSON?.get("data") as? String - val data = base64EncodedData?.let { Base64.decode(it) } - if (data != null) { - try { - Pair(MessageWrapper.unwrap(data), rawMessageAsJSON.get("hash") as? String) - } catch (e: Exception) { - Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.") - null - } - } else { - Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.") - null - } - } + private fun parseEnvelopes(rawMessages: List>): List> = rawMessages.mapNotNull { rawMessage -> + val base64EncodedData = rawMessage["data"] as? String + val data = base64EncodedData?.let(Base64::decode) + + data ?: Log.d("Loki", "Failed to decode data for message: ${rawMessage.prettifiedDescription()}.") + + data?.runCatching { MessageWrapper.unwrap(this) to rawMessage["hash"] as? String } + ?.onFailure { Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.") } + ?.getOrNull() } @Suppress("UNCHECKED_CAST") - private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: RawResponse): Map { - val swarms = rawResponse["swarm"] as? Map ?: return mapOf() - val result = swarms.mapNotNull { (hexSnodePublicKey, rawJSON) -> - val json = rawJSON as? Map ?: return@mapNotNull null - val isFailed = json["failed"] as? Boolean ?: false - val statusCode = json["code"] as? String - val reason = json["reason"] as? String - hexSnodePublicKey to if (isFailed) { + private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: RawResponse): Map = + (rawResponse["swarm"] as? Map)?.mapValuesNotNull { (hexSnodePublicKey, rawJSON) -> + val json = rawJSON as? Map ?: return@mapValuesNotNull null + if (json["failed"] as? Boolean == true) { + val reason = json["reason"] as? String + val statusCode = json["code"] as? String Log.e("Loki", "Failed to delete all messages from: $hexSnodePublicKey due to error: $reason ($statusCode).") false } else { @@ -922,17 +703,15 @@ object SnodeAPI { val signature = json["signature"] as String val snodePublicKey = Key.fromHexString(hexSnodePublicKey) // The signature looks like ( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] ) - val message = (userPublicKey + timestamp.toString() + hashes.joinToString(separator = "")).toByteArray() + val message = sequenceOf(userPublicKey, "$timestamp").plus(hashes).toByteArray() sodium.cryptoSignVerifyDetached(Base64.decode(signature), message, message.size, snodePublicKey.asBytes) } - } - return result.toMap() - } + } ?: mapOf() // endregion // Error Handling - internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Exception? { + internal fun handleSnodeError(statusCode: Int, json: Map<*, *>?, snode: Snode, publicKey: String? = null): Throwable? = runCatching { fun handleBadSnode() { val oldFailureCount = snodeFailureCount[snode] ?: 0 val newFailureCount = oldFailureCount + 1 @@ -940,56 +719,38 @@ object SnodeAPI { Log.d("Loki", "Couldn't reach snode at $snode; setting failure count to $newFailureCount.") if (newFailureCount >= snodeFailureThreshold) { Log.d("Loki", "Failure threshold reached for: $snode; dropping it.") - if (publicKey != null) { - dropSnodeFromSwarmIfNeeded(snode, publicKey) - } - snodePool = snodePool.toMutableSet().minus(snode).toSet() - Log.d("Loki", "Snode pool count: ${snodePool.count()}.") - snodeFailureCount[snode] = 0 + publicKey?.let { dropSnodeFromSwarmIfNeeded(snode, it) } + snodePool = (snodePool - snode).also { Log.d("Loki", "Snode pool count: ${it.count()}.") } + snodeFailureCount -= snode } } when (statusCode) { - 400, 500, 502, 503 -> { // Usually indicates that the snode isn't up to date - handleBadSnode() - } + // Usually indicates that the snode isn't up to date + 400, 500, 502, 503 -> handleBadSnode() 406 -> { Log.d("Loki", "The user's clock is out of sync with the service node network.") broadcaster.broadcast("clockOutOfSync") - return Error.ClockOutOfSync + throw Error.ClockOutOfSync } 421 -> { // The snode isn't associated with the given public key anymore - if (publicKey != null) { - fun invalidateSwarm() { - Log.d("Loki", "Invalidating swarm for: $publicKey.") - dropSnodeFromSwarmIfNeeded(snode, publicKey) - } - if (json != null) { - val snodes = parseSnodes(json) - if (snodes.isNotEmpty()) { - database.setSwarm(publicKey, snodes.toSet()) - } else { - invalidateSwarm() - } - } else { - invalidateSwarm() - } - } else { - Log.d("Loki", "Got a 421 without an associated public key.") - } + if (publicKey == null) Log.d("Loki", "Got a 421 without an associated public key.") + else json?.let(::parseSnodes) + ?.takeIf { it.isNotEmpty() } + ?.let { database.setSwarm(publicKey, it.toSet()) } + ?: dropSnodeFromSwarmIfNeeded(snode, publicKey).also { Log.d("Loki", "Invalidating swarm for: $publicKey.") } } 404 -> { Log.d("Loki", "404, probably no file found") - return Error.Generic + throw Error.Generic } else -> { handleBadSnode() Log.d("Loki", "Unhandled response code: ${statusCode}.") - return Error.Generic + throw Error.Generic } } - return null - } + }.exceptionOrNull() } // Type Aliases diff --git a/libsession/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt b/libsession/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt new file mode 100644 index 0000000000..9c55a2282a --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/snode/utilities/PromiseUtil.kt @@ -0,0 +1,13 @@ +package org.session.libsession.snode.utilities + +import nl.komponents.kovenant.Promise +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +suspend fun Promise.await(): T { + return suspendCoroutine { cont -> + success(cont::resume) + fail(cont::resumeWithException) + } +} \ No newline at end of file diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 12fdd4ceaf..5bf109843d 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import org.session.libsession.R +import org.session.libsession.utilities.TextSecurePreferences.Companion import org.session.libsession.utilities.TextSecurePreferences.Companion.AUTOPLAY_AUDIO_MESSAGES import org.session.libsession.utilities.TextSecurePreferences.Companion.CALL_NOTIFICATIONS_ENABLED import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_DARK @@ -20,6 +21,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_ import org.session.libsession.utilities.TextSecurePreferences.Companion.FOLLOW_SYSTEM_SETTINGS import org.session.libsession.utilities.TextSecurePreferences.Companion.HIDE_PASSWORD import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VACUUM_TIME +import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VERSION_CHECK import org.session.libsession.utilities.TextSecurePreferences.Companion.LEGACY_PREF_KEY_SELECTED_UI_MODE import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_DARK import org.session.libsession.utilities.TextSecurePreferences.Companion.OCEAN_LIGHT @@ -186,6 +188,8 @@ interface TextSecurePreferences { fun clearAll() fun getHidePassword(): Boolean fun setHidePassword(value: Boolean) + fun getLastVersionCheck(): Long + fun setLastVersionCheck() companion object { val TAG = TextSecurePreferences::class.simpleName @@ -272,6 +276,7 @@ interface TextSecurePreferences { const val AUTOPLAY_AUDIO_MESSAGES = "pref_autoplay_audio" const val FINGERPRINT_KEY_GENERATED = "fingerprint_key_generated" const val SELECTED_ACCENT_COLOR = "selected_accent_color" + const val LAST_VERSION_CHECK = "pref_last_version_check" const val HAS_RECEIVED_LEGACY_CONFIG = "has_received_legacy_config" const val HAS_FORCED_NEW_CONFIG = "has_forced_new_config" @@ -1541,6 +1546,14 @@ class AppTextSecurePreferences @Inject constructor( setLongPreference(LAST_VACUUM_TIME, System.currentTimeMillis()) } + override fun getLastVersionCheck(): Long { + return getLongPreference(LAST_VERSION_CHECK, 0) + } + + override fun setLastVersionCheck() { + setLongPreference(LAST_VERSION_CHECK, System.currentTimeMillis()) + } + override fun setShownCallNotification(): Boolean { val previousValue = getBooleanPreference(SHOWN_CALL_NOTIFICATION, false) if (previousValue) return false diff --git a/libsession/src/main/java/org/session/libsession/utilities/Util.kt b/libsession/src/main/java/org/session/libsession/utilities/Util.kt index 929f53e305..ae42d51068 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/Util.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/Util.kt @@ -13,6 +13,7 @@ import android.text.TextUtils import android.text.style.StyleSpan import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Util.SECURE_RANDOM import java.io.* import java.nio.charset.StandardCharsets import java.security.SecureRandom @@ -292,15 +293,10 @@ object Util { @JvmStatic fun getSecretBytes(size: Int): ByteArray { val secret = ByteArray(size) - getSecureRandom().nextBytes(secret) + SECURE_RANDOM.nextBytes(secret) return secret } - @JvmStatic - fun getSecureRandom(): SecureRandom { - return SecureRandom() - } - @JvmStatic fun getFirstNonEmpty(vararg values: String?): String? { for (value in values) { @@ -317,18 +313,14 @@ object Util { } @JvmStatic - fun getRandomElement(elements: Array): T { - return elements[SecureRandom().nextInt(elements.size)] - } + fun getRandomElement(elements: Array): T = elements[SECURE_RANDOM.nextInt(elements.size)] @JvmStatic fun getBoldedString(value: String?): CharSequence { if (value.isNullOrEmpty()) { return "" } - val spanned = SpannableString(value) - spanned.setSpan(StyleSpan(Typeface.BOLD), 0, - spanned.length, - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - return spanned + return SpannableString(value).also { + it.setSpan(StyleSpan(Typeface.BOLD), 0, it.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } } @JvmStatic @@ -366,34 +358,6 @@ object Util { val digitGroups = (Math.log10(sizeBytes.toDouble()) / Math.log10(1024.0)).toInt() return DecimalFormat("#,##0.#").format(sizeBytes / Math.pow(1024.0, digitGroups.toDouble())) + " " + units[digitGroups] } - - /** - * Compares two version strings (for example "1.8.0") - * - * @param version1 the first version string to compare. - * @param version2 the second version string to compare. - * @return an integer indicating the result of the comparison: - * - 0 if the versions are equal - * - a positive number if version1 is greater than version2 - * - a negative number if version1 is less than version2 - */ - @JvmStatic - fun compareVersions(version1: String, version2: String): Int { - val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 } - val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 } - - val maxLength = maxOf(parts1.size, parts2.size) - val paddedParts1 = parts1 + List(maxLength - parts1.size) { 0 } - val paddedParts2 = parts2 + List(maxLength - parts2.size) { 0 } - - for (i in 0 until maxLength) { - val compare = paddedParts1[i].compareTo(paddedParts2[i]) - if (compare != 0) { - return compare - } - } - return 0 - } } fun T.runIf(condition: Boolean, block: T.() -> R): R where T: R = if (condition) block() else this @@ -430,6 +394,12 @@ fun Iterable.associateByNotNull( for(e in this) { it[keySelector(e) ?: continue] = valueTransform(e) ?: continue } } +fun Map.mapValuesNotNull( + valueTransform: (Map.Entry) -> W? +): Map = mutableMapOf().also { + for(e in this) { it[e.key] = valueTransform(e) ?: continue } +} + /** * Groups elements of the original collection by the key returned by the given [keySelector] function * applied to each element and returns a map where each group key is associated with a list of @@ -440,3 +410,23 @@ fun Iterable.associateByNotNull( inline fun Iterable.groupByNotNull(keySelector: (E) -> K?): Map> = LinkedHashMap>().also { forEach { e -> keySelector(e)?.let { k -> it.getOrPut(k) { mutableListOf() } += e } } } + +/** + * Analogous to [buildMap], this function creates a [MutableMap] and populates it using the given [action]. + */ +inline fun buildMutableMap(action: MutableMap.() -> Unit): MutableMap = + mutableMapOf().apply(action) + +/** + * Converts a list of Pairs into a Map, filtering out any Pairs where the value is null. + * + * @param pairs The list of Pairs to convert. + * @return A Map with non-null values. + */ +fun Iterable>.toMapNotNull(): Map = + associateByNotNull(Pair::first, Pair::second) + +fun Sequence.toByteArray(): ByteArray = ByteArrayOutputStream().use { output -> + forEach { it.byteInputStream().use { input -> input.copyTo(output) } } + output.toByteArray() +} diff --git a/libsignal/src/main/java/org/session/libsignal/crypto/Random.kt b/libsignal/src/main/java/org/session/libsignal/crypto/Random.kt index 4f7687307b..c32eb78595 100644 --- a/libsignal/src/main/java/org/session/libsignal/crypto/Random.kt +++ b/libsignal/src/main/java/org/session/libsignal/crypto/Random.kt @@ -1,19 +1,23 @@ package org.session.libsignal.crypto -import java.security.SecureRandom +import org.session.libsignal.utilities.Util.SECURE_RANDOM /** * Uses `SecureRandom` to pick an element from this collection. */ -fun Collection.getRandomElementOrNull(): T? { +fun Collection.secureRandomOrNull(): T? { if (isEmpty()) return null - val index = SecureRandom().nextInt(size) // SecureRandom() should be cryptographically secure + val index = SECURE_RANDOM.nextInt(size) // SecureRandom should be cryptographically secure return elementAtOrNull(index) } /** * Uses `SecureRandom` to pick an element from this collection. + * + * @throws [NullPointerException] if the [Collection] is empty */ -fun Collection.getRandomElement(): T { - return getRandomElementOrNull()!! +fun Collection.secureRandom(): T { + return secureRandomOrNull()!! } + +fun Collection.shuffledRandom(): List = shuffled(SECURE_RANDOM) diff --git a/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java b/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java index f47a5f72b6..43734605ae 100644 --- a/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java +++ b/libsignal/src/main/java/org/session/libsignal/streams/ProfileCipherOutputStream.java @@ -1,13 +1,13 @@ package org.session.libsignal.streams; import static org.session.libsignal.crypto.CipherUtil.CIPHER_LOCK; +import static org.session.libsignal.utilities.Util.SECURE_RANDOM; import java.io.IOException; import java.io.OutputStream; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; @@ -80,7 +80,7 @@ public class ProfileCipherOutputStream extends DigestingOutputStream { private byte[] generateNonce() { byte[] nonce = new byte[12]; - new SecureRandom().nextBytes(nonce); + SECURE_RANDOM.nextBytes(nonce); return nonce; } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Base64.java b/libsignal/src/main/java/org/session/libsignal/utilities/Base64.java index 3ff38f76d3..35ec22e0e0 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Base64.java +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Base64.java @@ -1,5 +1,7 @@ package org.session.libsignal.utilities; +import androidx.annotation.NonNull; + /** *

Encodes and decodes to and from Base64 notation.

*

Homepage: http://iharder.net/base64.

@@ -714,7 +716,7 @@ public class Base64 * @throws NullPointerException if source array is null * @since 1.4 */ - public static String encodeBytes( byte[] source ) { + public static String encodeBytes(@NonNull byte[] source ) { // Since we're not going to have the GZIP encoding turned on, // we're not going to have an java.io.IOException thrown, so // we should not force the user to have to catch it. diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt b/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt index fd4a3f3702..4f8b20f7c6 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/HTTP.kt @@ -5,8 +5,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody -import okhttp3.Response -import java.security.SecureRandom +import org.session.libsignal.utilities.Util.SECURE_RANDOM import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit import javax.net.ssl.SSLContext @@ -35,7 +34,7 @@ object HTTP { override fun getAcceptedIssuers(): Array { return arrayOf() } } val sslContext = SSLContext.getInstance("SSL") - sslContext.init(null, arrayOf( trustManager ), SecureRandom()) + sslContext.init(null, arrayOf( trustManager ), SECURE_RANDOM) OkHttpClient().newBuilder() .sslSocketFactory(sslContext.socketFactory, trustManager) .hostnameVerifier { _, _ -> true } @@ -55,7 +54,7 @@ object HTTP { override fun getAcceptedIssuers(): Array { return arrayOf() } } val sslContext = SSLContext.getInstance("SSL") - sslContext.init(null, arrayOf( trustManager ), SecureRandom()) + sslContext.init(null, arrayOf( trustManager ), SECURE_RANDOM) return OkHttpClient().newBuilder() .sslSocketFactory(sslContext.socketFactory, trustManager) .hostnameVerifier { _, _ -> true } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt index f6b11754ad..675fe55f85 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Snode.kt @@ -1,9 +1,25 @@ package org.session.libsignal.utilities -class Snode(val address: String, val port: Int, val publicKeySet: KeySet?, val version: String) { +import android.annotation.SuppressLint +import android.util.LruCache + +/** + * Create a Snode from a "-" delimited String if valid, null otherwise. + */ +fun Snode(string: String): Snode? { + val components = string.split("-") + val address = components[0] + val port = components.getOrNull(1)?.toIntOrNull() ?: return null + val ed25519Key = components.getOrNull(2) ?: return null + val x25519Key = components.getOrNull(3) ?: return null + val version = components.getOrNull(4)?.let(Snode::Version) ?: Snode.Version.ZERO + return Snode(address, port, Snode.KeySet(ed25519Key, x25519Key), version) +} + +class Snode(val address: String, val port: Int, val publicKeySet: KeySet?, val version: Version) { val ip: String get() = address.removePrefix("https://") - public enum class Method(val rawValue: String) { + enum class Method(val rawValue: String) { GetSwarm("get_snodes_for_pubkey"), Retrieve("retrieve"), SendMessage("store"), @@ -19,17 +35,37 @@ class Snode(val address: String, val port: Int, val publicKeySet: KeySet?, val v data class KeySet(val ed25519Key: String, val x25519Key: String) - override fun equals(other: Any?): Boolean { - return if (other is Snode) { - address == other.address && port == other.port - } else { - false + override fun equals(other: Any?) = other is Snode && address == other.address && port == other.port + override fun hashCode(): Int = address.hashCode() xor port.hashCode() + override fun toString(): String = "$address:$port" + + companion object { + private val CACHE = LruCache(100) + + @SuppressLint("NotConstructor") + @Synchronized + fun Version(value: String) = CACHE[value] ?: Snode.Version(value).also { CACHE.put(value, it) } + + fun Version(parts: List) = Version(parts.joinToString(".")) + } + + @JvmInline + value class Version(val value: ULong) { + companion object { + val ZERO = Version(0UL) + private const val MASK_BITS = 16 + private const val MASK = 0xFFFFUL } - } - override fun hashCode(): Int { - return address.hashCode() xor port.hashCode() - } + internal constructor(value: String): this( + value.splitToSequence(".") + .take(4) + .map { it.toULongOrNull() ?: 0UL } + .foldIndexed(0UL) { i, acc, it -> + it.coerceAtMost(MASK) shl (3 - i) * MASK_BITS or acc + } + ) - override fun toString(): String { return "$address:$port" } + operator fun compareTo(other: Version): Int = value.compareTo(other.value) + } } diff --git a/libsignal/src/main/java/org/session/libsignal/utilities/Util.java b/libsignal/src/main/java/org/session/libsignal/utilities/Util.java index 2c9436485f..3b3b7aa5e6 100644 --- a/libsignal/src/main/java/org/session/libsignal/utilities/Util.java +++ b/libsignal/src/main/java/org/session/libsignal/utilities/Util.java @@ -12,12 +12,10 @@ import java.io.InputStream; import java.io.OutputStream; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; -import java.util.Arrays; import java.util.Collection; -import java.util.Collections; -import java.util.List; public class Util { + public static SecureRandom SECURE_RANDOM = new SecureRandom(); public static byte[] join(byte[]... input) { try { @@ -67,7 +65,7 @@ public class Util { } public static boolean isEmpty(String value) { - return value == null || value.trim().length() == 0; + return value == null || value.trim().isEmpty(); } public static byte[] getSecretBytes(int size) { @@ -80,13 +78,6 @@ public class Util { } } - public static byte[] getRandomLengthBytes(int maxSize) { - SecureRandom secureRandom = new SecureRandom(); - byte[] result = new byte[secureRandom.nextInt(maxSize) + 1]; - secureRandom.nextBytes(result); - return result; - } - public static String readFully(InputStream in) throws IOException { ByteArrayOutputStream bout = new ByteArrayOutputStream(); byte[] buffer = new byte[4096]; @@ -98,7 +89,7 @@ public class Util { in.close(); - return new String(bout.toByteArray()); + return bout.toString(); } public static void readFully(InputStream in, byte[] buffer) throws IOException { @@ -146,9 +137,4 @@ public class Util { } return (int)value; } - - public static List immutableList(T... elements) { - return Collections.unmodifiableList(Arrays.asList(elements.clone())); - } - }