diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8af9930684..d769563e80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 426 -val canonicalVersionName = "1.28.1" +val canonicalVersionCode = 427 +val canonicalVersionName = "1.28.2" val postFixSize = 10 val abiPostFix = mapOf( @@ -402,6 +402,7 @@ dependencies { implementation(libs.phrase) implementation(libs.copper.flow) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.guava) implementation(libs.kovenant) implementation(libs.kovenant.android) implementation(libs.opencsv) diff --git a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt index 3d4933e4a8..a79732c6b3 100644 --- a/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt +++ b/app/src/main/java/org/session/libsession/database/MessageDataProvider.kt @@ -39,9 +39,8 @@ interface MessageDataProvider { fun isDeletedMessage(id: MessageId): Boolean fun handleSuccessfulAttachmentUpload(attachmentId: Long, attachmentStream: SignalServiceAttachmentStream, attachmentKey: ByteArray, uploadResult: UploadResult) fun handleFailedAttachmentUpload(attachmentId: Long) - fun getMessageForQuote(timestamp: Long, author: Address): Triple? + fun getMessageForQuote(threadId: Long, timestamp: Long, author: Address): Triple? fun getAttachmentsAndLinkPreviewFor(mmsId: Long): List - fun getMessageBodyFor(timestamp: Long, author: String): String fun getAttachmentIDsFor(mmsMessageId: Long): List fun getLinkPreviewAttachmentIDFor(mmsMessageId: Long): Long? } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt index 1192366bf0..b10e71628a 100644 --- a/app/src/main/java/org/session/libsession/database/StorageProtocol.kt +++ b/app/src/main/java/org/session/libsession/database/StorageProtocol.kt @@ -92,7 +92,7 @@ interface StorageProtocol { fun addReceivedMessageTimestamp(timestamp: Long) fun removeReceivedMessageTimestamps(timestamps: Set) fun getAttachmentsForMessage(mmsMessageId: Long): List - fun getMessageBy(timestamp: Long, author: String): MessageRecord? + fun getMessageBy(threadId: Long, timestamp: Long, author: String): MessageRecord? fun updateSentTimestamp(messageId: MessageId, newTimestamp: Long) fun markAsResyncing(messageId: MessageId) fun markAsSyncing(messageId: MessageId) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 44ce02ebb8..7f7ac86059 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -71,7 +71,6 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager import java.security.SignatureException import javax.inject.Inject @@ -244,7 +243,8 @@ class ReceivedMessageHandler @Inject constructor( val timestamp = message.timestamp ?: return null val author = message.author ?: return null - val messageToDelete = storage.getMessageBy(timestamp, author) ?: return null + val threadId = message.threadID ?: return null + val messageToDelete = storage.getMessageBy(threadId, timestamp, author) ?: return null val messageIdToDelete = messageToDelete.messageId val messageType = messageToDelete.individualRecipient?.getType() @@ -326,7 +326,7 @@ class ReceivedMessageHandler @Inject constructor( Address.fromSerialized(quote.author) } - val messageInfo = messageDataProvider.getMessageForQuote(quote.id, author) + val messageInfo = messageDataProvider.getMessageForQuote(context.threadId, quote.id, author) quoteMessageBody = messageInfo?.third quoteModel = if (messageInfo != null) { val attachments = if (messageInfo.second) messageDataProvider.getAttachmentsAndLinkPreviewFor(messageInfo.first) else ArrayList() diff --git a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt index 9315ed424d..116d5fbc2c 100644 --- a/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/OnionRequestAPI.kt @@ -60,6 +60,8 @@ object OnionRequestAPI { .map { it.isNotEmpty() } .stateIn(GlobalScope, SharingStarted.Eagerly, paths.value.isNotEmpty()) + private val NON_PENALIZING_STATUSES = setOf(403, 404, 406, 425) + init { // Listen for the changes in paths and persist it to the db GlobalScope.launch { @@ -410,12 +412,20 @@ object OnionRequestAPI { } else { handleUnspecificError() } - } else if (destination is Destination.Server && exception.statusCode == 400) { - Log.d("Loki","Destination server returned code ${exception.statusCode} with message: $message") + } else if(exception.statusCode in NON_PENALIZING_STATUSES){ + // error codes that shouldn't penalize our path or drop snodes + // 404 is probably file server missing a file, don't rebuild path or mark a snode as bad here + Log.d("Loki","Request returned a non penalizing code ${exception.statusCode} with message: $message") + } + // we do not want to penalize the path/nodes when: + // - the exit node reached the server but the destination returned 5xx or 400 + // - the exit node couldn't reach its destination with a 5xx or 400, but the destination was a community (which we can know from the server's name being in the error message) + else if (destination is Destination.Server && + (exception.statusCode in 500..504 || exception.statusCode == 400) && + (exception is HTTPRequestFailedAtDestinationException || exception.body?.contains(destination.host) == true)) { + Log.d("Loki","Destination server error - Non path penalizing. Request returned code ${exception.statusCode} with message: $message") } else if (message == "Loki Server error") { Log.d("Loki", "message was $message") - } else if (exception.statusCode == 404) { - // 404 is probably file server missing a file, don't rebuild path or mark a snode as bad here } else { // Only drop snode/path if not receiving above two exception cases handleUnspecificError() } diff --git a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index e39d780823..ef3304e4f6 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -938,12 +938,6 @@ interface TextSecurePreferences { setBooleanPreference(context, FINGERPRINT_KEY_GENERATED, true) } - @JvmStatic - fun clearAll(context: Context) { - getDefaultSharedPreferences(context).edit().clear().commit() - } - - // ----- Get / set methods for if we have already warned the user that saving attachments will allow other apps to access them ----- // Note: We only ever show the warning dialog about this ONCE - when the user accepts this fact we write true to the flag & never show again. @JvmStatic @@ -1680,8 +1674,16 @@ class AppTextSecurePreferences @Inject constructor( return getBooleanPreference(AUTOPLAY_AUDIO_MESSAGES, false) } + /** + * Clear all prefs and reset our observables + */ override fun clearAll() { - getDefaultSharedPreferences(context).edit().clear().commit() + pushEnabled.update { false } + localNumberState.update { null } + postProLaunchState.update { false } + hiddenPasswordState.update { false } + + getDefaultSharedPreferences(context).edit(commit = true) { clear() } } override fun getHidePassword() = getBooleanPreference(HIDE_PASSWORD, false) diff --git a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt index fc29d07916..8f22f75bab 100644 --- a/app/src/main/java/org/session/libsignal/utilities/HTTP.kt +++ b/app/src/main/java/org/session/libsignal/utilities/HTTP.kt @@ -73,7 +73,8 @@ object HTTP { open class HTTPRequestFailedException( val statusCode: Int, - val json: Map<*, *>?, + val json: Map<*, *>? = null, + val body: String? = null, message: String = "HTTP request failed with status code $statusCode" ) : kotlin.Exception(message) class HTTPNoNetworkException : HTTPRequestFailedException(0, null, "No network connection") @@ -131,10 +132,10 @@ object HTTP { else -> defaultConnection }.newCall(request.build()).await().use { response -> when (val statusCode = response.code) { - 200 -> response.body!!.bytes() + in 200..299 -> response.body.bytes() else -> { Log.d("Loki", "${verb.rawValue} request to $url failed with status code: $statusCode.") - throw HTTPRequestFailedException(statusCode, null) + throw HTTPRequestFailedException(statusCode, body = response.body.string()) } } } @@ -143,8 +144,16 @@ object HTTP { if (!isConnectedToNetwork()) { throw HTTPNoNetworkException() } - // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI - throw HTTPRequestFailedException(0, null, "HTTP request failed due to: ${exception.message}") + if (exception !is HTTPRequestFailedException) { + + // Override the actual error so that we can correctly catch failed requests in OnionRequestAPI + throw HTTPRequestFailedException( + statusCode = 0, + message = "HTTP request failed due to: ${exception.message}" + ) + } else { + throw exception + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt index bfd4e8a996..93d5841385 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -143,7 +143,6 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), private var albumRailAdapter: MediaRailAdapter? = null private var windowInsetBottom = 0 - private var railHeight = 0 @OptIn(ExperimentalCoroutinesApi::class) override fun onCreate(bundle: Bundle?, ready: Boolean) { @@ -172,7 +171,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), windowInsetBottom = insets.bottom binding.toolbar.updatePadding(top = insets.top) - binding.mediaPreviewAlbumRailContainer.updatePadding(bottom = max(insets.bottom, binding.mediaPreviewAlbumRailContainer.paddingBottom)) + binding.mediaPreviewAlbumRailContainer.updatePadding(bottom = insets.bottom) updateControlsPosition() @@ -213,12 +212,9 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), * Updates the media controls' position based on the rail's position */ private fun updateControlsPosition() { - // the ypos of the controls is either the window bottom inset, or the rail height if there is a rail - // since the rail height takes the window inset into account with its padding - val totalBottomPadding = max( - windowInsetBottom, - railHeight + resources.getDimensionPixelSize(R.dimen.medium_spacing) - ) + val totalBottomPadding = windowInsetBottom + + binding.mediaPreviewAlbumRail.height+ + resources.getDimensionPixelSize(R.dimen.medium_spacing) adapter?.setControlsYPosition(totalBottomPadding) } @@ -431,7 +427,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), binding.mediaPreviewAlbumRailContainer.viewTreeObserver.removeOnGlobalLayoutListener( this ) - railHeight = binding.mediaPreviewAlbumRailContainer.height + updateControlsPosition() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt index 0fa83bf402..e65ced674c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareScreen.kt @@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.groups.compose.MemberItem import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -66,7 +67,7 @@ fun ShareScreen( fun ShareList( state: ShareViewModel.UIState, contacts: List, - hasConversations: Boolean, + hasConversations: Boolean?, onContactItemClicked: (address: Address) -> Unit, searchQuery: String, onSearchQueryChanged: (String) -> Unit, @@ -116,29 +117,41 @@ fun ShareList( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - if (!hasConversations) { - Text( - text = stringResource(id = R.string.conversationsNone), - modifier = Modifier.padding(top = LocalDimensions.current.spacing) - .align(Alignment.CenterHorizontally), - style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) - ) - } else { - LazyColumn( - state = scrollState, - contentPadding = PaddingValues(bottom = paddings.calculateBottomPadding()), - ) { - - items(contacts) { contacts -> - // Each member's view - MemberItem( - address = contacts.address, - onClick = onContactItemClicked, - title = contacts.name, - showProBadge = contacts.showProBadge, - showAsAdmin = false, - avatarUIData = contacts.avatarUIData - ) + when(hasConversations){ + // null means we don't yet have a result, so show a loader + null -> { + SmallCircularProgressIndicator( + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.CenterHorizontally), + ) + } + + false -> { + Text( + text = stringResource(id = R.string.conversationsNone), + modifier = Modifier.padding(top = LocalDimensions.current.spacing) + .align(Alignment.CenterHorizontally), + style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) + ) + } + + true -> { + LazyColumn( + state = scrollState, + contentPadding = PaddingValues(bottom = paddings.calculateBottomPadding()), + ) { + + items(contacts) { contacts -> + // Each member's view + MemberItem( + address = contacts.address, + onClick = onContactItemClicked, + title = contacts.name, + showProBadge = contacts.showProBadge, + showAsAdmin = false, + avatarUIData = contacts.avatarUIData + ) + } } } } @@ -201,6 +214,25 @@ private fun PreviewSelectEmptyContacts() { } } +@Preview +@Composable +private fun PreviewSelectFetchingContacts() { + val contacts = emptyList() + + PreviewTheme { + ShareList( + state = ShareViewModel.UIState(false), + contacts = contacts, + hasConversations = null, + onContactItemClicked = {}, + searchQuery = "", + onSearchQueryChanged = {}, + onSearchQueryClear = {}, + onBack = {}, + ) + } +} + @Preview @Composable private fun PreviewSelectEmptyContactsWithSearch() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt index df259eed83..4b3b3333a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ShareViewModel.kt @@ -71,10 +71,10 @@ class ShareViewModel @Inject constructor( ::filterContacts ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - val hasAnyConversations: StateFlow = + val hasAnyConversations: StateFlow = conversationRepository.observeConversationList() .map { it.isNotEmpty() } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) val uiEvents: SharedFlow get() = _uiEvents diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index f9b28899c6..449b49960a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -74,9 +74,9 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider? { + override fun getMessageForQuote(threadId: Long, timestamp: Long, author: Address): Triple? { val messagingDatabase = DatabaseComponent.get(context).mmsSmsDatabase() - val message = messagingDatabase.getMessageFor(timestamp, author) + val message = messagingDatabase.getMessageFor(threadId, timestamp, author) return if (message != null) Triple(message.id, message.isMms, message.body) else null } @@ -84,11 +84,6 @@ class DatabaseAttachmentProvider(context: Context, helper: Provider { return DatabaseComponent.get(context) .attachmentDatabase() diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt index 8aacba08a4..c86568de4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/RemoteFileDownloadWorker.kt @@ -288,10 +288,6 @@ class RemoteFileDownloadWorker @AssistedInject constructor( return File(downloadsDirectory(context), remote.sha256Hash()) } - fun cancelAll(context: Context) { - WorkManager.getInstance(context).cancelAllWorkByTag(TAG) - } - private fun uniqueWorkName(remote: RemoteFile): String { return "download-remote-file-${remote.sha256Hash()}" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/coil/RemoteFileFetcher.kt b/app/src/main/java/org/thoughtcrime/securesms/coil/RemoteFileFetcher.kt index 1b02e282a7..8a887a78ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/coil/RemoteFileFetcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/coil/RemoteFileFetcher.kt @@ -8,6 +8,7 @@ import coil3.decode.ImageSource import coil3.fetch.FetchResult import coil3.fetch.Fetcher import coil3.fetch.SourceFetchResult +import coil3.request.CachePolicy import coil3.request.Options import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.attachments.RemoteFileDownloadWorker class RemoteFileFetcher @AssistedInject constructor( @Assisted private val file: RemoteFile, + @Assisted private val options: Options, @param:ApplicationContext private val context: Context, val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, ) : Fetcher { @@ -32,12 +34,17 @@ class RemoteFileFetcher @AssistedInject constructor( // Check if the file already exists in the local storage, otherwise enqueue a download and // wait for it to complete. - val dataSource = if (!downloadedFile.exists()) { - RemoteFileDownloadWorker.enqueue(context, file) - .first { it?.state == WorkInfo.State.FAILED || it?.state == WorkInfo.State.SUCCEEDED } - DataSource.NETWORK - } else { - DataSource.DISK + val dataSource = when { + downloadedFile.exists() -> DataSource.DISK + options.networkCachePolicy == CachePolicy.ENABLED -> { + RemoteFileDownloadWorker.enqueue(context, file) + .first { it?.state == WorkInfo.State.FAILED || it?.state == WorkInfo.State.SUCCEEDED } + DataSource.NETWORK + } + else -> { + throw RuntimeException("RemoteFile doesn't exist locally and we aren't allowed to download" + + "from network") + } } val stream = localEncryptedFileInputStreamFactory.create(downloadedFile) @@ -62,14 +69,14 @@ class RemoteFileFetcher @AssistedInject constructor( @AssistedFactory abstract class Factory : Fetcher.Factory { - abstract fun create(remoteFile: RemoteFile): RemoteFileFetcher + abstract fun create(remoteFile: RemoteFile, options: Options): RemoteFileFetcher override fun create( data: RemoteFile, options: Options, imageLoader: ImageLoader ): Fetcher? { - return create(data) + return create(data, options) } } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java deleted file mode 100644 index a0646bc532..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ /dev/null @@ -1,160 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.os.Bundle; -import android.text.InputType; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; -import android.text.TextUtils.TruncateAt; -import android.text.style.RelativeSizeSpan; -import android.util.AttributeSet; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputConnection; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatEditText; -import androidx.core.os.BuildCompat; -import androidx.core.view.inputmethod.EditorInfoCompat; -import androidx.core.view.inputmethod.InputConnectionCompat; -import androidx.core.view.inputmethod.InputContentInfoCompat; - -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsignal.utilities.Log; - -public class ComposeText extends AppCompatEditText { - - private CharSequence hint; - private SpannableString subHint; - - @Nullable private InputPanel.MediaListener mediaListener; - @Nullable private CursorPositionChangedListener cursorPositionChangedListener; - - public ComposeText(Context context) { - super(context); - initialize(); - } - - public ComposeText(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public ComposeText(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - public String getTextTrimmed(){ - return getText().toString().trim(); - } - - @Override - protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - super.onLayout(changed, left, top, right, bottom); - - if (!TextUtils.isEmpty(hint)) { - if (!TextUtils.isEmpty(subHint)) { - setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint)) - .append("\n") - .append(ellipsizeToWidth(subHint))); - } else { - setHint(ellipsizeToWidth(hint)); - } - } - } - - @Override - protected void onSelectionChanged(int selStart, int selEnd) { - super.onSelectionChanged(selStart, selEnd); - - if (cursorPositionChangedListener != null) { - cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd); - } - } - - private CharSequence ellipsizeToWidth(CharSequence text) { - return TextUtils.ellipsize(text, - getPaint(), - getWidth() - getPaddingLeft() - getPaddingRight(), - TruncateAt.END); - } - - public void setHint(@NonNull String hint, @Nullable CharSequence subHint) { - this.hint = hint; - - if (subHint != null) { - this.subHint = new SpannableString(subHint); - this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); - } else { - this.subHint = null; - } - - if (this.subHint != null) { - super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint)) - .append("\n") - .append(ellipsizeToWidth(this.subHint))); - } else { - super.setHint(ellipsizeToWidth(this.hint)); - } - } - - @Override - public InputConnection onCreateInputConnection(EditorInfo editorInfo) { - InputConnection inputConnection = super.onCreateInputConnection(editorInfo); - - if(TextSecurePreferences.isEnterSendsEnabled(getContext())) { - editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION; - } - - if (mediaListener == null) return inputConnection; - if (inputConnection == null) return null; - - EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] {"image/jpeg", "image/png", "image/gif"}); - return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener)); - } - - private void initialize() { - if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) { - setImeOptions(getImeOptions() | 16777216); - } - } - - private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener { - - private static final String TAG = CommitContentListener.class.getSimpleName(); - - private final InputPanel.MediaListener mediaListener; - - private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) { - this.mediaListener = mediaListener; - } - - @Override - public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { - if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { - try { - inputContentInfo.requestPermission(); - } catch (Exception e) { - Log.w(TAG, e); - return false; - } - } - - if (inputContentInfo.getDescription().getMimeTypeCount() > 0) { - mediaListener.onMediaSelected(inputContentInfo.getContentUri(), - inputContentInfo.getDescription().getMimeType(0)); - - return true; - } - - return false; - } - } - - public interface CursorPositionChangedListener { - void onCursorPositionChanged(int start, int end); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java deleted file mode 100644 index ad375c0992..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.net.Uri; -import android.util.AttributeSet; -import android.widget.LinearLayout; - -import androidx.annotation.NonNull; - -public class InputPanel extends LinearLayout { - - public InputPanel(Context context) { - super(context); - } - - public InputPanel(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public interface MediaListener { - void onMediaSelected(@NonNull Uri uri, String contentType); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java deleted file mode 100644 index 58b6d3a459..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SearchToolbar.java +++ /dev/null @@ -1,133 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.animation.Animator; -import android.content.Context; -import android.util.AttributeSet; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewAnimationUtils; -import android.widget.EditText; - -import androidx.annotation.MainThread; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.SearchView; -import androidx.appcompat.widget.Toolbar; - -import org.thoughtcrime.securesms.util.AnimationCompleteListener; - -import network.loki.messenger.R; - -public class SearchToolbar extends Toolbar { - - private float x, y; - private MenuItem searchItem; - private SearchListener listener; - - public SearchToolbar(Context context) { - super(context); - initialize(); - } - - public SearchToolbar(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public SearchToolbar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - private void initialize() { - setNavigationIcon(getContext().getResources().getDrawable(R.drawable.ic_x)); - inflateMenu(R.menu.conversation_list_search); - - this.searchItem = getMenu().findItem(R.id.action_filter_search); - SearchView searchView = (SearchView) searchItem.getActionView(); - EditText searchText = searchView.findViewById(androidx.appcompat.R.id.search_src_text); - - searchView.setSubmitButtonEnabled(false); - - if (searchText != null) searchText.setHint(R.string.search); - else searchView.setQueryHint(getResources().getString(R.string.search)); - - searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { - @Override - public boolean onQueryTextSubmit(String query) { - if (listener != null) listener.onSearchTextChange(query); - return true; - } - - @Override - public boolean onQueryTextChange(String newText) { return onQueryTextSubmit(newText); } - }); - - searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { - @Override - public boolean onMenuItemActionExpand(MenuItem item) { - return true; - } - - @Override - public boolean onMenuItemActionCollapse(MenuItem item) { - hide(); - return true; - } - }); - - setNavigationOnClickListener(v -> hide()); - } - - @MainThread - public void display(float x, float y) { - if (getVisibility() != View.VISIBLE) { - this.x = x; - this.y = y; - - searchItem.expandActionView(); - - Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, 0, getWidth()); - animator.setDuration(400); - - setVisibility(View.VISIBLE); - animator.start(); - } - } - - public void collapse() { - searchItem.collapseActionView(); - } - - @MainThread - private void hide() { - if (getVisibility() == View.VISIBLE) { - - if (listener != null) listener.onSearchClosed(); - - Animator animator = ViewAnimationUtils.createCircularReveal(this, (int)x, (int)y, getWidth(), 0); - animator.setDuration(400); - animator.addListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - setVisibility(View.INVISIBLE); - } - }); - animator.start(); - } - } - - public boolean isVisible() { - return getVisibility() == View.VISIBLE; - } - - @MainThread - public void setListener(SearchListener listener) { - this.listener = listener; - } - - public interface SearchListener { - void onSearchTextChange(String text); - void onSearchClosed(); - } - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java deleted file mode 100644 index 5d369de3b3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SquareFrameLayout.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.thoughtcrime.securesms.components; - -import android.content.Context; -import android.content.res.TypedArray; -import android.util.AttributeSet; -import android.widget.FrameLayout; - -import network.loki.messenger.R; - -public class SquareFrameLayout extends FrameLayout { - - private final boolean squareHeight; - - @SuppressWarnings("unused") - public SquareFrameLayout(Context context) { - this(context, null); - } - - @SuppressWarnings("unused") - public SquareFrameLayout(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - @SuppressWarnings("unused") - public SquareFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - - if (attrs != null) { - TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SquareFrameLayout, 0, 0); - this.squareHeight = typedArray.getBoolean(R.styleable.SquareFrameLayout_square_height, false); - typedArray.recycle(); - } else { - this.squareHeight = false; - } - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - //noinspection SuspiciousNameCombination - if (squareHeight) super.onMeasure(heightMeasureSpec, heightMeasureSpec); - else super.onMeasure(widthMeasureSpec, widthMeasureSpec); - } -} 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 f4edf63b8e..87f4a1fbef 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 @@ -172,6 +172,7 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.giph.ui.GiphyActivity import org.thoughtcrime.securesms.groups.GroupMembersActivity import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.home.search.searchName import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil @@ -202,7 +203,6 @@ import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.PaddedImageSpan import org.thoughtcrime.securesms.util.SaveAttachmentTask -import org.thoughtcrime.securesms.util.adapter.applyImeBottomPadding import org.thoughtcrime.securesms.util.adapter.handleScrollToBottom import org.thoughtcrime.securesms.util.adapter.runWhenLaidOut import org.thoughtcrime.securesms.util.drawToBitmap @@ -527,6 +527,25 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) { super.onCreate(savedInstanceState, isReady) + + // Check if address is null before proceeding with initialization + if ( + IntentCompat.getParcelableExtra( + intent, + ADDRESS, + Address.Conversable::class.java + ) == null && + intent.data?.getQueryParameter(ADDRESS).isNullOrEmpty() + ) { + Log.w(TAG, "ConversationActivityV2 launched without ADDRESS extra - Returning home") + val intent = Intent(this, HomeActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + startActivity(intent) + finish() + return + } + binding = ActivityConversationV2Binding.inflate(layoutInflater) setContentView(binding.root) 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 22343fc747..53e8cd1841 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -52,6 +52,7 @@ import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.ThreadUtils.queue import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.database.MmsDatabase.Companion.MESSAGE_BOX import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageId @@ -1295,8 +1296,9 @@ class MmsDatabase @Inject constructor( private fun getQuote(cursor: Cursor): Quote? { val quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)) val quoteAuthor = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)) + val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)) if (quoteId == 0L || quoteAuthor.isNullOrBlank()) return null - val retrievedQuote = mmsSmsDatabase.get().getMessageFor(quoteId, quoteAuthor, false) + val retrievedQuote = mmsSmsDatabase.get().getMessageFor(threadId, quoteId, quoteAuthor, false) val quoteText = retrievedQuote?.body val quoteMissing = retrievedQuote == null val quoteDeck = ( @@ -1448,6 +1450,9 @@ class MmsDatabase @Inject constructor( WHERE ($MESSAGE_BOX & ${MmsSmsColumns.Types.EXPIRATION_TIMER_UPDATE_BIT}) != 0; """ + const val ADD_LAST_MESSAGE_INDEX: String = + "CREATE INDEX mms_thread_id_date_sent_index ON $TABLE_NAME ($THREAD_ID, $DATE_SENT)" + private val MMS_PROJECTION: Array = arrayOf( "$TABLE_NAME.$ID AS $ID", THREAD_ID, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 1e6c21e67e..a87e4fb822 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -30,7 +30,6 @@ import androidx.annotation.Nullable; import net.zetetic.database.sqlcipher.SQLiteDatabase; -import net.zetetic.database.sqlcipher.SQLiteQueryBuilder; import org.jetbrains.annotations.NotNull; import org.session.libsession.messaging.utilities.UpdateMessageData; @@ -107,7 +106,7 @@ public MmsSmsDatabase(Context context, Provider databaseHel final String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + " AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor); return reader.getNext(); } @@ -124,13 +123,15 @@ public MmsSmsDatabase(Context context, Provider databaseHel } } - public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor) { - return getMessageFor(timestamp, serializedAuthor, true); + public @Nullable MessageRecord getMessageFor(long threadId, long timestamp, String serializedAuthor) { + return getMessageFor(threadId, timestamp, serializedAuthor, true); } - public @Nullable MessageRecord getMessageFor(long timestamp, String serializedAuthor, boolean getQuote) { + public @Nullable MessageRecord getMessageFor(long threadId, long timestamp, String serializedAuthor, boolean getQuote) { + String selection = MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp + " AND " + + MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { MmsSmsDatabase.Reader reader = readerFor(cursor, getQuote); MessageRecord messageRecord; @@ -148,59 +149,12 @@ public MmsSmsDatabase(Context context, Provider databaseHel return null; } - public @Nullable MessageRecord getSentMessageFor(long timestamp, String serializedAuthor) { - // Early exit if the author is not us - boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); - if (!isOwnNumber) { - Log.i(TAG, "Asked to find sent messages but provided author is not us - returning null."); - return null; - } - - try (Cursor cursor = queryTables(PROJECTION, MmsSmsColumns.NORMALIZED_DATE_SENT + " = " + timestamp, null, null)) { - MmsSmsDatabase.Reader reader = readerFor(cursor); - - MessageRecord messageRecord; - while ((messageRecord = reader.getNext()) != null) { - if (messageRecord.isOutgoing()) - { - return messageRecord; - } - } - } - Log.i(TAG, "Could not find any message sent from us at provided timestamp - returning null."); - return null; - } - - public MessageRecord getLastSentMessageRecordFromSender(long threadId, String serializedAuthor) { - // Early exit if the author is not us - boolean isOwnNumber = Util.isOwnNumber(context, serializedAuthor); - if (!isOwnNumber) { - Log.i(TAG, "Asked to find last sent message but provided author is not us - returning null."); - return null; - } - - String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - - // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, order, null)) { - try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { - MessageRecord messageRecord; - while ((messageRecord = reader.getNext()) != null) { - if (messageRecord.isOutgoing()) { return messageRecord; } - } - } - } - Log.i(TAG, "Could not find last sent message from us in given thread - returning null."); - return null; - } - @Nullable public MessageId getLastSentMessageID(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND NOT " + MmsSmsColumns.IS_DELETED; - try (final Cursor cursor = queryTables(PROJECTION, selection, order, null)) { + try (final Cursor cursor = queryTables(PROJECTION, selection, true, null, order, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -214,66 +168,8 @@ public MessageId getLastSentMessageID(long threadId) { return null; } - public @Nullable MessageRecord getMessageFor(long timestamp, Address author) { - return getMessageFor(timestamp, author.toString()); - } - - public long getPreviousPage(long threadId, long fromTime, int limit) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" ASC"; - String selection = MmsSmsColumns.THREAD_ID+" = "+threadId - + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > "+fromTime; - String limitStr = ""+limit; - long sent = -1; - Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); - if (cursor == null) return sent; - Reader reader = readerFor(cursor); - if (!cursor.move(limit)) { - cursor.moveToLast(); - } - MessageRecord record = reader.getCurrent(); - sent = record.getDateSent(); - reader.close(); - return sent; - } - - public Cursor getConversationPage(long threadId, long fromTime, long toTime, int limit) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = "+threadId - + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" <= " + fromTime; - String limitStr = null; - if (toTime != -1L) { - selection += " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > "+toTime; - } else { - limitStr = ""+limit; - } - - return queryTables(PROJECTION, selection, order, limitStr); - } - - public boolean hasNextPage(long threadId, long toTime) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = "+threadId - + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" < " + toTime; // check if there's at least one message before the `toTime` - Cursor cursor = queryTables(PROJECTION, selection, order, null); - boolean hasNext = false; - if (cursor != null) { - hasNext = cursor.getCount() > 0; - cursor.close(); - } - return hasNext; - } - - public boolean hasPreviousPage(long threadId, long fromTime) { - String order = MmsSmsColumns.NORMALIZED_DATE_SENT+" DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = "+threadId - + " AND "+MmsSmsColumns.NORMALIZED_DATE_SENT+" > " + fromTime; // check if there's at least one message after the `fromTime` - Cursor cursor = queryTables(PROJECTION, selection, order, null); - boolean hasNext = false; - if (cursor != null) { - hasNext = cursor.getCount() > 0; - cursor.close(); - } - return hasNext; + public @Nullable MessageRecord getMessageFor(long threadId, long timestamp, Address author) { + return getMessageFor(threadId, timestamp, author.toString()); } public Cursor getConversation(long threadId, boolean reverse, long offset, long limit) { @@ -281,7 +177,7 @@ public Cursor getConversation(long threadId, boolean reverse, long offset, long String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; - Cursor cursor = queryTables(PROJECTION, selection, order, limitStr); + Cursor cursor = queryTables(PROJECTION, selection, true, null, order, limitStr); return cursor; } @@ -293,7 +189,7 @@ public Cursor getConversationSnippet(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - return queryTables(PROJECTION, selection, order, null); + return queryTables(PROJECTION, selection, true, null, order, null); } public List getRecentChatMemberAddresses(long threadId, int limit) { @@ -302,7 +198,7 @@ public List getRecentChatMemberAddresses(long threadId, int limit) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = String.valueOf(limit); - try (Cursor cursor = queryTables(projection, selection, order, limitStr)) { + try (Cursor cursor = queryTables(projection, selection, true, null, order, limitStr)) { List addresses = new ArrayList<>(); while (cursor != null && cursor.moveToNext()) { String address = cursor.getString(0); @@ -339,7 +235,7 @@ public Set getAllMessageRecordsFromSenderInThread(long threadId, Set identifiedMessages = new HashSet(); // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -355,7 +251,7 @@ public List> getAllMessageRecordsBefore(long threadI List> identifiedMessages = new ArrayList<>(); // Try everything with resources so that they auto-close on end of scope - try (Cursor cursor = queryTables(PROJECTION, selection, null, null)) { + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null)) { try (MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord messageRecord; while ((messageRecord = reader.getNext()) != null) { @@ -374,7 +270,7 @@ public List> getAllMessagesWithHash(long threadId) { String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; List> identifiedMessages = new ArrayList<>(); - try (Cursor cursor = queryTables(PROJECTION, selection, null, null); + try (Cursor cursor = queryTables(PROJECTION, selection, true, null, null, null); MmsSmsDatabase.Reader reader = readerFor(cursor)) { MessageRecord record; @@ -388,15 +284,18 @@ public List> getAllMessagesWithHash(long threadId) { return identifiedMessages; } + /** + * @param includeReactions Whether to query reactions as well. + */ @Nullable - public MessageRecord getLastMessage(long threadId) { + public MessageRecord getLastMessage(long threadId, boolean includeReactions, boolean getQuote) { String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; // make sure the last message isn't marked as deleted String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND " + "NOT " + MmsSmsColumns.IS_DELETED; - try (Cursor cursor = queryTables(PROJECTION, selection, order, "1")) { - return readerFor(cursor).getNext(); + try (Cursor cursor = queryTables(PROJECTION, selection, includeReactions, null, order, "1")) { + return readerFor(cursor, getQuote).getNext(); } } @@ -413,7 +312,7 @@ public Cursor getUnreadIncomingForNotifications(int maxRows) { String selection = "(" + READ + " = 0 AND " + NOTIFIED + " = 0 AND NOT (" + outgoing + "))"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = maxRows > 0 ? String.valueOf(maxRows) : null; - return queryTables(PROJECTION, selection, order, limitStr); + return queryTables(PROJECTION, selection, true, null, order, limitStr); } public Cursor getOutgoingWithUnseenReactionsForNotifications(int maxRows) { @@ -423,14 +322,12 @@ public Cursor getOutgoingWithUnseenReactionsForNotifications(int maxRows) { " FROM " + ThreadDatabase.TABLE_NAME + " WHERE " + ThreadDatabase.ID + " = " + MmsSmsColumns.THREAD_ID; - String selection = - "(" + outgoing + ")" + - " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + " IS NOT NULL" + - " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + " > (" + lastSeenQuery + ")"; + String reactionSelection = ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + " IS NOT NULL" + + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + " > (" + lastSeenQuery + ")"; String order = MmsSmsColumns.NORMALIZED_DATE_SENT + " DESC"; String limitStr = maxRows > 0 ? String.valueOf(maxRows) : null; - return queryTables(PROJECTION, selection, order, limitStr); + return queryTables(PROJECTION, outgoing, true, reactionSelection, order, limitStr); } public Set
getAllReferencedAddresses() { @@ -439,7 +336,7 @@ public Set
getAllReferencedAddresses() { " AND " + MmsSmsColumns.ADDRESS + " != ''"; Set
out = new HashSet<>(); - try (Cursor cursor = queryTables(projection, selection, null, null)) { + try (Cursor cursor = queryTables(projection, selection, true, null, null, null)) { while (cursor != null && cursor.moveToNext()) { String serialized = cursor.getString(0); try { @@ -467,7 +364,7 @@ private String buildOutgoingTypesList() { public int getUnreadCount(long threadId) { String selection = READ + " = 0 AND " + NOTIFIED + " = 0 AND " + MmsSmsColumns.THREAD_ID + " = " + threadId; - Cursor cursor = queryTables(PROJECTION, selection, null, null); + Cursor cursor = queryTables(new String[] { ID }, selection, true, null, null, null); try { return cursor != null ? cursor.getCount() : 0; @@ -510,7 +407,7 @@ public int getMessagePositionInConversation(long threadId, long sentTimestamp, @ String order = MmsSmsColumns.NORMALIZED_DATE_SENT + (reverse ? " DESC" : " ASC"); String selection = MmsSmsColumns.THREAD_ID + " = " + threadId; - try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, order, null)) { + try (Cursor cursor = queryTables(new String[]{ MmsSmsColumns.NORMALIZED_DATE_SENT, MmsSmsColumns.ADDRESS }, selection, true, null, order, null)) { String serializedAddress = address.toString(); boolean isOwnNumber = Util.isOwnNumber(context, address.toString()); @@ -655,243 +552,21 @@ public static void migrateLegacyCommunityAddresses2(final SQLiteDatabase db) { migrateLegacyCommunityAddresses2(db, MmsDatabase.TABLE_NAME); } - private Cursor queryTables(String[] projection, String selection, String order, String limit) { - String reactionsColumn = "json_group_array(json_object(" + - "'" + ReactionDatabase.ROW_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.ROW_ID + ", " + - "'" + ReactionDatabase.MESSAGE_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + ", " + - "'" + ReactionDatabase.IS_MMS + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + ", " + - "'" + ReactionDatabase.AUTHOR_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.AUTHOR_ID + ", " + - "'" + ReactionDatabase.EMOJI + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.EMOJI + ", " + - "'" + ReactionDatabase.SERVER_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SERVER_ID + ", " + - "'" + ReactionDatabase.COUNT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.COUNT + ", " + - "'" + ReactionDatabase.SORT_ID + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.SORT_ID + ", " + - "'" + ReactionDatabase.DATE_SENT + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_SENT + ", " + - "'" + ReactionDatabase.DATE_RECEIVED + "', " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.DATE_RECEIVED + - ")) AS " + ReactionDatabase.REACTION_JSON_ALIAS; - String[] mmsProjection = {MmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, - MmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, - MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AS " + MmsSmsColumns.ID, - "'MMS::' || " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID - + " || '::' || " + MmsDatabase.DATE_SENT - + " AS " + MmsSmsColumns.UNIQUE_ROW_ID, - "json_group_array(json_object(" + - "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + - "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + - "'" + AttachmentDatabase.MMS_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + "," + - "'" + AttachmentDatabase.SIZE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + ", " + - "'" + AttachmentDatabase.FILE_NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FILE_NAME + ", " + - "'" + AttachmentDatabase.DATA + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DATA + ", " + - "'" + AttachmentDatabase.THUMBNAIL + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.THUMBNAIL + ", " + - "'" + AttachmentDatabase.CONTENT_TYPE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_TYPE + ", " + - "'" + AttachmentDatabase.CONTENT_LOCATION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " + - "'" + AttachmentDatabase.FAST_PREFLIGHT_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + ", " + - "'" + AttachmentDatabase.VOICE_NOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + ", " + - "'" + AttachmentDatabase.WIDTH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + ", " + - "'" + AttachmentDatabase.HEIGHT + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + ", " + - "'" + AttachmentDatabase.QUOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " + - "'" + AttachmentDatabase.CONTENT_DISPOSITION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_DISPOSITION + ", " + - "'" + AttachmentDatabase.NAME + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", " + - "'" + AttachmentDatabase.TRANSFER_STATE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFER_STATE + ", " + - "'" + AttachmentDatabase.CAPTION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", " + - "'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " + - "'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " + - "'" + AttachmentDatabase.AUDIO_DURATION + "', ifnull(" + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.AUDIO_DURATION + ", -1), " + - "'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + - ")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, - reactionsColumn, - SmsDatabase.BODY, - MmsDatabase.MESSAGE_CONTENT, - READ, MmsSmsColumns.THREAD_ID, - SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, - MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, - MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, - MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS, - MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT, - MmsSmsColumns.MISMATCHED_IDENTITIES, - MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED, - NOTIFIED, - MmsDatabase.NETWORK_FAILURE, TRANSPORT, - MmsDatabase.QUOTE_ID, - MmsDatabase.QUOTE_AUTHOR, - MmsDatabase.QUOTE_BODY, - MmsDatabase.QUOTE_MISSING, - MmsDatabase.QUOTE_ATTACHMENT, - MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS, - MmsSmsColumns.HAS_MENTION, - "mms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH, - }; - - String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, - SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, - MmsSmsColumns.ID, - "'SMS::' || " + MmsSmsColumns.ID - + " || '::' || " + SmsDatabase.DATE_SENT - + " AS " + MmsSmsColumns.UNIQUE_ROW_ID, - "NULL AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS, - reactionsColumn, - SmsDatabase.BODY, - MmsSmsColumns.MESSAGE_CONTENT, - READ, MmsSmsColumns.THREAD_ID, - SmsDatabase.TYPE, SmsDatabase.ADDRESS, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE, - MmsDatabase.MESSAGE_BOX, SmsDatabase.STATUS, MmsDatabase.PART_COUNT, - MmsDatabase.CONTENT_LOCATION, MmsDatabase.TRANSACTION_ID, - MmsDatabase.MESSAGE_SIZE, MmsDatabase.EXPIRY, MmsDatabase.STATUS, - MmsSmsColumns.DELIVERY_RECEIPT_COUNT, MmsSmsColumns.READ_RECEIPT_COUNT, - MmsSmsColumns.MISMATCHED_IDENTITIES, - MmsSmsColumns.SUBSCRIPTION_ID, MmsSmsColumns.EXPIRES_IN, MmsSmsColumns.EXPIRE_STARTED, - NOTIFIED, - MmsDatabase.NETWORK_FAILURE, TRANSPORT, - MmsDatabase.QUOTE_ID, - MmsDatabase.QUOTE_AUTHOR, - MmsDatabase.QUOTE_BODY, - MmsDatabase.QUOTE_MISSING, - MmsDatabase.QUOTE_ATTACHMENT, - MmsDatabase.SHARED_CONTACTS, - MmsDatabase.LINK_PREVIEWS, - MmsSmsColumns.HAS_MENTION, - "sms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH, - }; - - SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); - SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); - - mmsQueryBuilder.setDistinct(true); - smsQueryBuilder.setDistinct(true); - - smsQueryBuilder.setTables(SmsDatabase.TABLE_NAME + - " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + - " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 0" + - " LEFT OUTER JOIN " + LokiMessageDatabase.smsHashTable + " AS sms_hash" + - " ON sms_hash.message_id = " + SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID); - mmsQueryBuilder.setTables(MmsDatabase.TABLE_NAME + - " LEFT OUTER JOIN " + AttachmentDatabase.TABLE_NAME + - " ON " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + - " LEFT OUTER JOIN " + ReactionDatabase.TABLE_NAME + - " ON " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.MESSAGE_ID + " = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " AND " + ReactionDatabase.TABLE_NAME + "." + ReactionDatabase.IS_MMS + " = 1" + - " LEFT OUTER JOIN " + LokiMessageDatabase.mmsHashTable + " AS mms_hash" + - " ON mms_hash.message_id = " + MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID); - - - Set mmsColumnsPresent = new HashSet<>(); - mmsColumnsPresent.add(MmsSmsColumns.ID); - mmsColumnsPresent.add(READ); - mmsColumnsPresent.add(MmsSmsColumns.THREAD_ID); - mmsColumnsPresent.add(MmsSmsColumns.MESSAGE_CONTENT); - mmsColumnsPresent.add(MmsSmsColumns.BODY); - mmsColumnsPresent.add(MmsSmsColumns.ADDRESS); - mmsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID); - mmsColumnsPresent.add(MmsSmsColumns.DELIVERY_RECEIPT_COUNT); - mmsColumnsPresent.add(MmsSmsColumns.READ_RECEIPT_COUNT); - mmsColumnsPresent.add(MmsSmsColumns.MISMATCHED_IDENTITIES); - mmsColumnsPresent.add(MmsSmsColumns.SUBSCRIPTION_ID); - mmsColumnsPresent.add(MmsSmsColumns.EXPIRES_IN); - mmsColumnsPresent.add(MmsSmsColumns.EXPIRE_STARTED); - mmsColumnsPresent.add(MmsDatabase.MESSAGE_TYPE); - mmsColumnsPresent.add(MmsDatabase.MESSAGE_BOX); - mmsColumnsPresent.add(MmsDatabase.DATE_SENT); - mmsColumnsPresent.add(MmsDatabase.DATE_RECEIVED); - mmsColumnsPresent.add(MmsDatabase.PART_COUNT); - mmsColumnsPresent.add(MmsDatabase.CONTENT_LOCATION); - mmsColumnsPresent.add(MmsDatabase.TRANSACTION_ID); - mmsColumnsPresent.add(MmsDatabase.MESSAGE_SIZE); - mmsColumnsPresent.add(MmsDatabase.EXPIRY); - mmsColumnsPresent.add(NOTIFIED); - mmsColumnsPresent.add(MmsDatabase.STATUS); - mmsColumnsPresent.add(MmsDatabase.NETWORK_FAILURE); - mmsColumnsPresent.add(MmsSmsColumns.HAS_MENTION); - mmsColumnsPresent.add("mms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH); - - mmsColumnsPresent.add(AttachmentDatabase.ROW_ID); - mmsColumnsPresent.add(AttachmentDatabase.UNIQUE_ID); - mmsColumnsPresent.add(AttachmentDatabase.MMS_ID); - mmsColumnsPresent.add(AttachmentDatabase.SIZE); - mmsColumnsPresent.add(AttachmentDatabase.FILE_NAME); - mmsColumnsPresent.add(AttachmentDatabase.DATA); - mmsColumnsPresent.add(AttachmentDatabase.THUMBNAIL); - mmsColumnsPresent.add(AttachmentDatabase.CONTENT_TYPE); - mmsColumnsPresent.add(AttachmentDatabase.CONTENT_LOCATION); - mmsColumnsPresent.add(AttachmentDatabase.DIGEST); - mmsColumnsPresent.add(AttachmentDatabase.FAST_PREFLIGHT_ID); - mmsColumnsPresent.add(AttachmentDatabase.VOICE_NOTE); - mmsColumnsPresent.add(AttachmentDatabase.WIDTH); - mmsColumnsPresent.add(AttachmentDatabase.HEIGHT); - mmsColumnsPresent.add(AttachmentDatabase.QUOTE); - mmsColumnsPresent.add(AttachmentDatabase.STICKER_PACK_ID); - mmsColumnsPresent.add(AttachmentDatabase.STICKER_PACK_KEY); - mmsColumnsPresent.add(AttachmentDatabase.STICKER_ID); - mmsColumnsPresent.add(AttachmentDatabase.CAPTION); - mmsColumnsPresent.add(AttachmentDatabase.CONTENT_DISPOSITION); - mmsColumnsPresent.add(AttachmentDatabase.NAME); - mmsColumnsPresent.add(AttachmentDatabase.TRANSFER_STATE); - mmsColumnsPresent.add(AttachmentDatabase.ATTACHMENT_JSON_ALIAS); - mmsColumnsPresent.add(MmsDatabase.QUOTE_ID); - mmsColumnsPresent.add(MmsDatabase.QUOTE_AUTHOR); - mmsColumnsPresent.add(MmsDatabase.QUOTE_BODY); - mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING); - mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT); - mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS); - mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS); - mmsColumnsPresent.add(ReactionDatabase.MESSAGE_ID); - mmsColumnsPresent.add(ReactionDatabase.IS_MMS); - mmsColumnsPresent.add(ReactionDatabase.AUTHOR_ID); - mmsColumnsPresent.add(ReactionDatabase.EMOJI); - mmsColumnsPresent.add(ReactionDatabase.SERVER_ID); - mmsColumnsPresent.add(ReactionDatabase.COUNT); - mmsColumnsPresent.add(ReactionDatabase.SORT_ID); - mmsColumnsPresent.add(ReactionDatabase.DATE_SENT); - mmsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED); - mmsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS); - - Set smsColumnsPresent = new HashSet<>(); - smsColumnsPresent.add(MmsSmsColumns.ID); - smsColumnsPresent.add(MmsSmsColumns.BODY); - smsColumnsPresent.add(MmsSmsColumns.ADDRESS); - smsColumnsPresent.add(MmsSmsColumns.ADDRESS_DEVICE_ID); - smsColumnsPresent.add(READ); - smsColumnsPresent.add(MmsSmsColumns.THREAD_ID); - smsColumnsPresent.add(MmsSmsColumns.DELIVERY_RECEIPT_COUNT); - smsColumnsPresent.add(MmsSmsColumns.READ_RECEIPT_COUNT); - smsColumnsPresent.add(MmsSmsColumns.MISMATCHED_IDENTITIES); - smsColumnsPresent.add(MmsSmsColumns.SUBSCRIPTION_ID); - smsColumnsPresent.add(MmsSmsColumns.EXPIRES_IN); - smsColumnsPresent.add(MmsSmsColumns.EXPIRE_STARTED); - smsColumnsPresent.add(NOTIFIED); - smsColumnsPresent.add(SmsDatabase.TYPE); - smsColumnsPresent.add(SmsDatabase.SUBJECT); - smsColumnsPresent.add(SmsDatabase.DATE_SENT); - smsColumnsPresent.add(SmsDatabase.DATE_RECEIVED); - smsColumnsPresent.add(SmsDatabase.STATUS); - smsColumnsPresent.add(MmsSmsColumns.HAS_MENTION); - smsColumnsPresent.add(ReactionDatabase.ROW_ID); - smsColumnsPresent.add(ReactionDatabase.MESSAGE_ID); - smsColumnsPresent.add(ReactionDatabase.IS_MMS); - smsColumnsPresent.add(ReactionDatabase.AUTHOR_ID); - smsColumnsPresent.add(ReactionDatabase.EMOJI); - smsColumnsPresent.add(ReactionDatabase.SERVER_ID); - smsColumnsPresent.add(ReactionDatabase.COUNT); - smsColumnsPresent.add(ReactionDatabase.SORT_ID); - smsColumnsPresent.add(ReactionDatabase.DATE_SENT); - smsColumnsPresent.add(ReactionDatabase.DATE_RECEIVED); - smsColumnsPresent.add(ReactionDatabase.REACTION_JSON_ALIAS); - smsColumnsPresent.add("sms_hash.server_hash AS " + MmsSmsColumns.SERVER_HASH); - - @SuppressWarnings("deprecation") - String mmsSubQuery = mmsQueryBuilder.buildUnionSubQuery(TRANSPORT, mmsProjection, mmsColumnsPresent, 5, MMS_TRANSPORT, selection, null, MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID, null); - @SuppressWarnings("deprecation") - String smsSubQuery = smsQueryBuilder.buildUnionSubQuery(TRANSPORT, smsProjection, smsColumnsPresent, 5, SMS_TRANSPORT, selection, null, SmsDatabase.TABLE_NAME + "." + SmsDatabase.ID, null); - - SQLiteQueryBuilder unionQueryBuilder = new SQLiteQueryBuilder(); - String unionQuery = unionQueryBuilder.buildUnionQuery(new String[] {smsSubQuery, mmsSubQuery}, order, limit); - - SQLiteQueryBuilder outerQueryBuilder = new SQLiteQueryBuilder(); - outerQueryBuilder.setTables("(" + unionQuery + ")"); - - @SuppressWarnings("deprecation") - String query = outerQueryBuilder.buildQuery(projection, null, null, null, null, null, null); - + private Cursor queryTables( + @NonNull String[] projection, + @Nullable String selection, + boolean includeReactions, + @Nullable String additionalReactionSelection, + @Nullable String order, + @Nullable String limit) { SQLiteDatabase db = getReadableDatabase(); - return db.rawQuery(query, null); + String query = MmsSmsDatabaseSQLKt.buildMmsSmsCombinedQuery(projection, + selection, + includeReactions, + additionalReactionSelection, + order, + limit); + return db.rawQuery(query, null); } public Reader readerFor(@NonNull Cursor cursor) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt new file mode 100644 index 0000000000..bc77f39d23 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabaseSQL.kt @@ -0,0 +1,238 @@ +package org.thoughtcrime.securesms.database + +/** + * Build a combined query to fetch both MMS and SMS messages in one go, the high level idea is to + * use a UNION between two SELECT statements, one for MMS and one for SMS. And they will need + * to have the same projection so we'll also do some aliasing on them. This can be illustrated as: + * + * For each message, we will perform sub-query to reaction/attachment database to query the relevant + * data. We try not to use JOIN as they screw up performance by impacting the index selection. + * + * ```sqlite + * SELECT sms_fields, + * (query reaction table) AS reactions, + * (query hash table) AS server_hash + * FROM sms + * + * UNION ALL + * + * SELECT + * mms_fields, + * (query reaction table) AS reactions, + * (query attachment table) AS attachments, + * (query hash table) AS server_hash + * FROM mms + * ``` + */ +fun buildMmsSmsCombinedQuery( + projections: Array, + selection: String?, + includeReactions: Boolean, + reactionSelection: String?, + order: String?, + limit: String? +): String { + // The query parts that fetch all reactions for a given message, and group them into a JSON array + val reactionsQueryParts = """ + SELECT json_group_array( + json_object( + '${ReactionDatabase.ROW_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.ROW_ID}, + '${ReactionDatabase.MESSAGE_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID}, + '${ReactionDatabase.IS_MMS}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS}, + '${ReactionDatabase.AUTHOR_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.AUTHOR_ID}, + '${ReactionDatabase.EMOJI}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.EMOJI}, + '${ReactionDatabase.SERVER_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.SERVER_ID}, + '${ReactionDatabase.COUNT}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.COUNT}, + '${ReactionDatabase.SORT_ID}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.SORT_ID}, + '${ReactionDatabase.DATE_SENT}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.DATE_SENT}, + '${ReactionDatabase.DATE_RECEIVED}', ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.DATE_RECEIVED} + ) + ) + FROM ${ReactionDatabase.TABLE_NAME} + """ + + // Subquery to grab sms' server hash + val smsHashQuery = """ + SELECT server_hash + FROM ${LokiMessageDatabase.smsHashTable} sms_hash + WHERE sms_hash.message_id = ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + """ + + // Custom where statement for reactions if provided + val additionalReactionSelection = reactionSelection?.let { " AND ($it)" }.orEmpty() + + // If reactions are not requested, we just return an empty JSON array + val smsReactionQuery = if (includeReactions) { + """($reactionsQueryParts + WHERE + ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID} = ${SmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + AND NOT ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS} + $additionalReactionSelection)""" + } else { + "'[]'" + } + + val whereStatement = selection?.let { "WHERE $it" }.orEmpty() + + // The main query for SMS messages + val smsQuery = """ + SELECT + ${SmsDatabase.DATE_SENT} AS ${MmsSmsColumns.NORMALIZED_DATE_SENT}, + ${SmsDatabase.DATE_RECEIVED} AS ${MmsSmsColumns.NORMALIZED_DATE_RECEIVED}, + ${MmsSmsColumns.ID}, + 'SMS::' || ${MmsSmsColumns.ID} || '::' || ${SmsDatabase.DATE_SENT} AS ${MmsSmsColumns.UNIQUE_ROW_ID}, + NULL AS ${AttachmentDatabase.ATTACHMENT_JSON_ALIAS}, + $smsReactionQuery AS ${ReactionDatabase.REACTION_JSON_ALIAS}, + ${SmsDatabase.BODY}, + NULL AS ${MmsSmsColumns.MESSAGE_CONTENT}, + ${MmsSmsColumns.READ}, + ${MmsSmsColumns.THREAD_ID}, + ${SmsDatabase.TYPE}, + ${SmsDatabase.ADDRESS}, + ${SmsDatabase.ADDRESS_DEVICE_ID}, + ${SmsDatabase.SUBJECT}, + NULL AS ${MmsDatabase.MESSAGE_TYPE}, + NULL AS ${MmsDatabase.MESSAGE_BOX}, + ${SmsDatabase.STATUS}, + NULL AS ${MmsDatabase.PART_COUNT}, + NULL AS ${MmsDatabase.CONTENT_LOCATION}, + NULL AS ${MmsDatabase.TRANSACTION_ID}, + NULL AS ${MmsDatabase.MESSAGE_SIZE}, + NULL AS ${MmsDatabase.EXPIRY}, + NULL AS ${MmsDatabase.STATUS}, + ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, + ${MmsSmsColumns.READ_RECEIPT_COUNT}, + ${MmsSmsColumns.MISMATCHED_IDENTITIES}, + ${MmsSmsColumns.SUBSCRIPTION_ID}, + ${MmsSmsColumns.EXPIRES_IN}, + ${MmsSmsColumns.EXPIRE_STARTED}, + ${MmsSmsColumns.NOTIFIED}, + NULL AS ${MmsDatabase.NETWORK_FAILURE}, + '${MmsSmsDatabase.SMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, + NULL AS ${MmsDatabase.QUOTE_ID}, + NULL AS ${MmsDatabase.QUOTE_AUTHOR}, + NULL AS ${MmsDatabase.QUOTE_BODY}, + NULL AS ${MmsDatabase.QUOTE_MISSING}, + NULL AS ${MmsDatabase.QUOTE_ATTACHMENT}, + NULL AS ${MmsDatabase.SHARED_CONTACTS}, + NULL AS ${MmsDatabase.LINK_PREVIEWS}, + ${MmsSmsColumns.HAS_MENTION}, + ($smsHashQuery) AS ${MmsSmsColumns.SERVER_HASH} + FROM ${SmsDatabase.TABLE_NAME} + $whereStatement + """ + + // Subquery to grab mms' server hash + val mmsHashQuery = """ + SELECT server_hash + FROM ${LokiMessageDatabase.mmsHashTable} mms_hash + WHERE mms_hash.message_id = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + """ + + // The subquery that fetches all attachments for a given MMS message, and group them into a JSON array + val attachmentQuery = """ + SELECT json_group_array( + json_object( + '${AttachmentDatabase.ROW_ID}', a.${AttachmentDatabase.ROW_ID}, + '${AttachmentDatabase.UNIQUE_ID}', a.${AttachmentDatabase.UNIQUE_ID}, + '${AttachmentDatabase.MMS_ID}', a.${AttachmentDatabase.MMS_ID}, + '${AttachmentDatabase.SIZE}', a.${AttachmentDatabase.SIZE}, + '${AttachmentDatabase.FILE_NAME}', a.${AttachmentDatabase.FILE_NAME}, + '${AttachmentDatabase.DATA}', a.${AttachmentDatabase.DATA}, + '${AttachmentDatabase.THUMBNAIL}', a.${AttachmentDatabase.THUMBNAIL}, + '${AttachmentDatabase.CONTENT_TYPE}', a.${AttachmentDatabase.CONTENT_TYPE}, + '${AttachmentDatabase.CONTENT_LOCATION}', a.${AttachmentDatabase.CONTENT_LOCATION}, + '${AttachmentDatabase.FAST_PREFLIGHT_ID}', a.${AttachmentDatabase.FAST_PREFLIGHT_ID}, + '${AttachmentDatabase.VOICE_NOTE}', a.${AttachmentDatabase.VOICE_NOTE}, + '${AttachmentDatabase.WIDTH}', a.${AttachmentDatabase.WIDTH}, + '${AttachmentDatabase.HEIGHT}', a.${AttachmentDatabase.HEIGHT}, + '${AttachmentDatabase.QUOTE}', a.${AttachmentDatabase.QUOTE}, + '${AttachmentDatabase.CONTENT_DISPOSITION}', a.${AttachmentDatabase.CONTENT_DISPOSITION}, + '${AttachmentDatabase.NAME}', a.${AttachmentDatabase.NAME}, + '${AttachmentDatabase.TRANSFER_STATE}', a.${AttachmentDatabase.TRANSFER_STATE}, + '${AttachmentDatabase.CAPTION}', a.${AttachmentDatabase.CAPTION}, + '${AttachmentDatabase.STICKER_PACK_ID}', a.${AttachmentDatabase.STICKER_PACK_ID}, + '${AttachmentDatabase.STICKER_PACK_KEY}', a.${AttachmentDatabase.STICKER_PACK_KEY}, + '${AttachmentDatabase.AUDIO_DURATION}', ifnull(a.${AttachmentDatabase.AUDIO_DURATION}, -1), + '${AttachmentDatabase.STICKER_ID}', a.${AttachmentDatabase.STICKER_ID} + ) + ) + FROM ${AttachmentDatabase.TABLE_NAME} AS a + WHERE a.${AttachmentDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + """ + + // Custom where statement for reactions if provided + val mmsReactionQuery = if (includeReactions) { + """($reactionsQueryParts + WHERE + ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.MESSAGE_ID} = ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} + AND ${ReactionDatabase.TABLE_NAME}.${ReactionDatabase.IS_MMS} + $additionalReactionSelection)""" + } else { + "'[]'" + } + + // The main query for MMS messages + val mmsQuery = """ + SELECT + ${MmsDatabase.DATE_SENT} AS ${MmsSmsColumns.NORMALIZED_DATE_SENT}, + ${MmsDatabase.DATE_RECEIVED} AS ${MmsSmsColumns.NORMALIZED_DATE_RECEIVED}, + ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} AS ${MmsSmsColumns.ID}, + 'MMS::' || ${MmsDatabase.TABLE_NAME}.${MmsSmsColumns.ID} || '::' || ${MmsDatabase.DATE_SENT} AS ${MmsSmsColumns.UNIQUE_ROW_ID}, + ($attachmentQuery) AS ${AttachmentDatabase.ATTACHMENT_JSON_ALIAS}, + $mmsReactionQuery AS ${ReactionDatabase.REACTION_JSON_ALIAS}, + ${MmsSmsColumns.BODY}, + ${MmsSmsColumns.MESSAGE_CONTENT}, + ${MmsSmsColumns.READ}, + ${MmsSmsColumns.THREAD_ID}, + NULL AS ${SmsDatabase.TYPE}, + ${MmsSmsColumns.ADDRESS}, + ${MmsSmsColumns.ADDRESS_DEVICE_ID}, + NULL AS ${SmsDatabase.SUBJECT}, + ${MmsDatabase.MESSAGE_TYPE}, + ${MmsDatabase.MESSAGE_BOX}, + NULL AS ${SmsDatabase.STATUS}, + ${MmsDatabase.PART_COUNT}, + ${MmsDatabase.CONTENT_LOCATION}, + ${MmsDatabase.TRANSACTION_ID}, + ${MmsDatabase.MESSAGE_SIZE}, + ${MmsDatabase.EXPIRY}, + ${MmsDatabase.STATUS}, + ${MmsSmsColumns.DELIVERY_RECEIPT_COUNT}, + ${MmsSmsColumns.READ_RECEIPT_COUNT}, + ${MmsSmsColumns.MISMATCHED_IDENTITIES}, + ${MmsSmsColumns.SUBSCRIPTION_ID}, + ${MmsSmsColumns.EXPIRES_IN}, + ${MmsSmsColumns.EXPIRE_STARTED}, + ${MmsSmsColumns.NOTIFIED}, + ${MmsDatabase.NETWORK_FAILURE}, + '${MmsSmsDatabase.MMS_TRANSPORT}' AS ${MmsSmsDatabase.TRANSPORT}, + ${MmsDatabase.QUOTE_ID}, + ${MmsDatabase.QUOTE_AUTHOR}, + ${MmsDatabase.QUOTE_BODY}, + ${MmsDatabase.QUOTE_MISSING}, + ${MmsDatabase.QUOTE_ATTACHMENT}, + ${MmsDatabase.SHARED_CONTACTS}, + ${MmsDatabase.LINK_PREVIEWS}, + ${MmsSmsColumns.HAS_MENTION}, + ($mmsHashQuery) AS ${MmsSmsColumns.SERVER_HASH} + FROM ${MmsDatabase.TABLE_NAME} + $whereStatement + """ + + val orderStatement = order?.let { "ORDER BY $it" }.orEmpty() + val limitStatement = limit?.let { "LIMIT $it" }.orEmpty() + + return """ + WITH combined AS ( + $smsQuery + UNION ALL + $mmsQuery + ) + + SELECT ${projections.joinToString(", ")} + FROM combined + $orderStatement + $limitStatement + """ +} 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 b0c91e538f..ea88d50a4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -155,6 +155,8 @@ public class SmsDatabase extends MessagingDatabase { "DROP TABLE " + TEMP_TABLE_NAME }; + public static String ADD_LAST_MESSAGE_INDEX = "CREATE INDEX sms_thread_id_date_sent_index ON " + TABLE_NAME + " (" + THREAD_ID + "," + DATE_SENT + ")"; + public static final String ADD_IS_DELETED_COLUMN = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + IS_DELETED_COLUMN_DEF; public static final String ADD_IS_GROUP_UPDATE_COLUMN = "ALTER TABLE " + TABLE_NAME +" ADD COLUMN " + IS_GROUP_UPDATE +" BOOL GENERATED ALWAYS AS (" + TYPE +" & " + GROUP_UPDATE_MESSAGE_BIT +" != 0) VIRTUAL"; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 3e6498360f..d3b175264d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -73,7 +73,6 @@ import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.FilenameUtils import org.thoughtcrime.securesms.util.SessionMetaProtocol -import java.time.Instant import javax.inject.Inject import javax.inject.Provider import javax.inject.Singleton @@ -481,10 +480,10 @@ open class Storage @Inject constructor( SessionMetaProtocol.removeTimestamps(timestamps) } - override fun getMessageBy(timestamp: Long, author: String): MessageRecord? { + override fun getMessageBy(threadId: Long, timestamp: Long, author: String): MessageRecord? { val database = mmsSmsDatabase val address = fromSerialized(author) - return database.getMessageFor(timestamp, address) + return database.getMessageFor(threadId, timestamp, address) } override fun updateSentTimestamp( @@ -636,7 +635,7 @@ open class Storage @Inject constructor( ) val mmsDB = mmsDatabase val mmsSmsDB = mmsSmsDatabase - if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) { + if (mmsSmsDB.getMessageFor(threadID, sentTimestamp, userPublicKey) != null) { Log.w(TAG, "Bailing from insertOutgoingInfoMessage because we believe the message has already been sent!") return null } @@ -797,7 +796,7 @@ open class Storage @Inject constructor( val mmsDB = mmsDatabase val mmsSmsDB = mmsSmsDatabase // check for conflict here, not returning duplicate in case it's different - if (mmsSmsDB.getMessageFor(sentTimestamp, userPublicKey) != null) return null + if (mmsSmsDB.getMessageFor(threadID, sentTimestamp, userPublicKey) != null) return null val infoMessageID = mmsDB.insertMessageOutbox( infoMessage, threadID, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 9465781ae8..26e01384f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -859,7 +859,7 @@ public ThreadRecord getCurrent() { MessageRecord lastMessage = null; if (count > 0) { - lastMessage = mmsSmsDatabase.get().getLastMessage(threadId); + lastMessage = mmsSmsDatabase.get().getLastMessage(threadId, false, false); } final GroupThreadStatus groupThreadStatus; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index ae56d16c0f..5c4c081cbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -99,9 +99,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV51 = 72; private static final int lokiV52 = 73; private static final int lokiV53 = 74; + private static final int lokiV54 = 75; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV53; + private static final int DATABASE_VERSION = lokiV54; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -258,6 +259,9 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(CommunityDatabase.MIGRATE_CREATE_TABLE); executeStatements(db, CommunityDatabase.Companion.getMIGRATE_DROP_OLD_TABLES()); + + db.execSQL(SmsDatabase.ADD_LAST_MESSAGE_INDEX); + db.execSQL(MmsDatabase.ADD_LAST_MESSAGE_INDEX); } @Override @@ -587,6 +591,11 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { MmsSmsDatabase.migrateLegacyCommunityAddresses2(db); } + if (oldVersion < lokiV54) { + db.execSQL(SmsDatabase.ADD_LAST_MESSAGE_INDEX); + db.execSQL(MmsDatabase.ADD_LAST_MESSAGE_INDEX); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java index 93293bcff0..188df96411 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java +++ b/app/src/main/java/org/thoughtcrime/securesms/giph/ui/GiphyActivityToolbar.java @@ -93,6 +93,7 @@ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { if (actionId == EditorInfo.IME_ACTION_SEARCH) { InputMethodManager inputMethodManager = (InputMethodManager)getContext().getSystemService(Context.INPUT_METHOD_SERVICE); inputMethodManager.hideSoftInputFromWindow(searchText.getWindowToken(), 0); + return true; } return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt index b225f45744..fa3f8c8726 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/search/GlobalSearchViewModel.kt @@ -18,12 +18,10 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.search.model.SearchResult -import org.thoughtcrime.securesms.util.observeChanges import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt index 92b98db3ed..f5f11730f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.kt @@ -31,6 +31,7 @@ import android.text.TextUtils import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import coil3.ImageLoader import com.squareup.phrase.Phrase import network.loki.messenger.R import network.loki.messenger.libsession_util.util.BlindKeyAPI @@ -72,6 +73,7 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject +import javax.inject.Provider import kotlin.concurrent.Volatile /** @@ -87,7 +89,8 @@ class DefaultMessageNotifier @Inject constructor( private val threadDatabase: ThreadDatabase, private val recipientRepository: RecipientRepository, private val mmsSmsDatabase: MmsSmsDatabase, - private val textSecurePreferences: TextSecurePreferences + private val textSecurePreferences: TextSecurePreferences, + private val imageLoader: Provider, ) : MessageNotifier { override fun setVisibleThread(threadId: Long) { visibleThread = threadId @@ -351,7 +354,8 @@ class DefaultMessageNotifier @Inject constructor( val builder = SingleRecipientNotificationBuilder( context, getNotificationPrivacy(context), - avatarUtils + avatarUtils, + imageLoader, ) builder.putStringExtra(CONTENT_SIGNATURE, contentSignature) @@ -722,7 +726,7 @@ class DefaultMessageNotifier @Inject constructor( } } - Log.w(TAG, "Adding incoming message notification: ${body}") + Log.w(TAG, "Adding incoming message notification: messageLength = ${body.length}") // Add incoming message notification notificationState.addNotification( @@ -777,7 +781,7 @@ class DefaultMessageNotifier @Inject constructor( Log.w( TAG, - "Adding reaction notification: ${emoji} to our message ID ${record.getId()}" + "Adding reaction notification to our message ID ${record.getId()}" ) notificationState.addNotification( diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt index dcce84bcd2..ffaa0e1ef8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationHandler.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.notifications import android.content.Context +import androidx.work.WorkInfo +import androidx.work.WorkManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -10,8 +12,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.guava.await import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import org.session.libsession.database.userAuth @@ -25,28 +28,28 @@ import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton -private const val TAG = "PushRegistrationHandler" - /** - * A class that listens to the config, user's preference, token changes and - * register/unregister push notification accordingly. + * PN registration source of truth using per-account periodic workers. + * + * Periodic workers must be created with tags: + * - "pn-register-periodic" + * - "pn-acc-" + * - "pn-tfp-" * - * This class DOES NOT handle the legacy groups push notification. */ @Singleton -class PushRegistrationHandler -@Inject -constructor( +class PushRegistrationHandler @Inject constructor( private val configFactory: ConfigFactory, private val preferences: TextSecurePreferences, private val tokenFetcher: TokenFetcher, - @param:ApplicationContext private val context: Context, + @ApplicationContext private val context: Context, private val registry: PushRegistryV2, private val storage: Storage, - @param:ManagerScope private val scope: CoroutineScope + @ManagerScope private val scope: CoroutineScope ) : OnAppStartupComponent { private var job: Job? = null @@ -62,83 +65,171 @@ constructor( .onStart { emit(Unit) }, preferences.watchLocalNumber(), preferences.pushEnabled, - tokenFetcher.token, - ) { _, myAccountId, enabled, token -> - if (!enabled || myAccountId == null || storage.getUserED25519KeyPair() == null || token.isNullOrEmpty()) { - return@combine emptySet() + tokenFetcher.token + ) { _, _, enabled, token -> + val desired = + if (enabled && hasCoreIdentity()) + desiredSubscriptions() + else emptySet() + Triple(enabled, token, desired) + } + .distinctUntilChanged() + .collect { (pushEnabled, token, desiredIds) -> + try { + reconcileWithWorkManager(pushEnabled, token, desiredIds) + } catch (t: Throwable) { + Log.e(TAG, "Reconciliation failed", t) } + } + } + } + + private suspend fun reconcileWithWorkManager( + pushEnabled: Boolean, + token: String?, + activeAccounts: Set + ) { + val wm = WorkManager.getInstance(context) + + // Read existing push periodic workers and parse (AccountId, tokenFingerprint) from tags. + val periodicInfos = wm.getWorkInfosByTag(TAG_PERIODIC).await() + .filter { it.state != WorkInfo.State.CANCELLED && it.state != WorkInfo.State.FAILED } - setOf(SubscriptionKey(AccountId(myAccountId), token)) + getGroupSubscriptions(token) + Log.d(TAG, "We currently have ${periodicInfos.size} push periodic workers") + + val accountsAlreadyRegistered: Map = buildMap { + for (info in periodicInfos) { + val id = parseAccountId(info) ?: continue + val token = parseTokenFingerprint(info) ?: continue + put(id, token) } - .scan(emptySet() to emptySet()) { acc, current -> - acc.second to current - } - .collect { (prev, current) -> - val added = current - prev - val removed = prev - current - if (added.isNotEmpty()) { - Log.d(TAG, "Adding ${added.size} new subscriptions") - } + } - if (removed.isNotEmpty()) { - Log.d(TAG, "Removing ${removed.size} subscriptions") + // If push disabled or identity missing → cancel all and try to deregister. + if (!pushEnabled || !hasCoreIdentity()) { + val toCancel = accountsAlreadyRegistered.keys + if (toCancel.isNotEmpty()) { + Log.d(TAG, "Push disabled/identity missing; cancelling ${toCancel.size} PN periodic works") + } + supervisorScope { + toCancel.forEach { id -> + launch { + PushRegistrationWorker.cancelAll(context, id) + tryUnregister(token, id) } + } + } + return + } - for (key in added) { - PushRegistrationWorker.schedule( - context = context, - token = key.token, - accountId = key.accountId, - ) - } + val currentFingerprint = token?.let { tokenFingerprint(it) } - supervisorScope { - for (key in removed) { - PushRegistrationWorker.cancelRegistration( - context = context, - accountId = key.accountId, - ) - - launch { - Log.d(TAG, "Unregistering push token for account: ${key.accountId}") - try { - val swarmAuth = swarmAuthForAccount(key.accountId) - ?: throw IllegalStateException("No SwarmAuth found for account: ${key.accountId}") - - registry.unregister( - token = key.token, - swarmAuth = swarmAuth, - ) - - Log.d(TAG, "Successfully unregistered push token for account: ${key.accountId}") - } catch (e: Exception) { - if (e !is CancellationException) { - Log.e(TAG, "Failed to unregister push token for account: ${key.accountId}", e) - } - } - } - } - } + // Add missing (ensure periodic + run now) — only if we have a token. + val accountsToAdd = activeAccounts - accountsAlreadyRegistered.keys + if (accountsToAdd.isNotEmpty()) Log.d(TAG, "Adding ${accountsToAdd.size} PN registrations") + if (!token.isNullOrEmpty()) { + accountsToAdd.forEach { id -> + PushRegistrationWorker.ensurePeriodic(context, id, token, replace = false) // KEEP + PushRegistrationWorker.scheduleImmediate(context, id, token) // run now + } + } + + // Token rotation: replace periodic where fingerprint mismatches. + if (!token.isNullOrEmpty()) { + var replaced = 0 + activeAccounts.forEach { id -> + val tokenFingerprint = accountsAlreadyRegistered[id] ?: return@forEach + if (tokenFingerprint != currentFingerprint) { + PushRegistrationWorker.ensurePeriodic(context, id, token, replace = true) // REPLACE + PushRegistrationWorker.scheduleImmediate(context, id, token) + replaced++ + } + } + if (replaced > 0) Log.d(TAG, "Replaced $replaced periodic PN workers due to token rotation") + } + + // Removed subscriptions: cancel workers & attempt deregister. + val accountToRemove = accountsAlreadyRegistered.keys - activeAccounts + if (accountToRemove.isNotEmpty()) Log.d(TAG, "Removing ${accountToRemove.size} PN registrations") + supervisorScope { + accountToRemove.forEach { id -> + launch { + PushRegistrationWorker.cancelAll(context, id) + tryUnregister(token, id) } + } + } + } + + /** + * Build desired subscriptions: self (local number) + any group that shouldPoll. + * */ + private fun desiredSubscriptions(): Set = buildSet { + preferences.getLocalNumber()?.let { add(AccountId(it)) } + val groups = configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } + groups.filter { it.shouldPoll } + .mapTo(this) { AccountId(it.groupAccountId) } + } + + private fun hasCoreIdentity(): Boolean { + return preferences.getLocalNumber() != null && storage.getUserED25519KeyPair() != null + } + + /** + * Try to deregister if we still have credentials and a token to sign with. + * Safe to no-op if token/auth missing (e.g., keys already deleted). + */ + private suspend fun tryUnregister(token: String?, accountId: AccountId) { + if (token.isNullOrEmpty()) return + val auth = swarmAuthForAccount(accountId) ?: return + try { + Log.d(TAG, "Unregistering PN for $accountId") + registry.unregister(token = token, swarmAuth = auth) + Log.d(TAG, "Unregistered PN for $accountId") + } catch (e: Exception) { + if (e !is CancellationException) { + Log.e(TAG, "Unregister failed for $accountId", e) + } else { + throw e + } } } private fun swarmAuthForAccount(accountId: AccountId): SwarmAuth? { return when (accountId.prefix) { IdPrefix.STANDARD -> storage.userAuth?.takeIf { it.accountId == accountId } - IdPrefix.GROUP -> configFactory.getGroupAuth(accountId) - else -> null // Unsupported account ID prefix + IdPrefix.GROUP -> configFactory.getGroupAuth(accountId) + else -> null } } - private fun getGroupSubscriptions( - token: String - ): Set { - return configFactory.withUserConfigs { it.userGroups.allClosedGroupInfo() } - .asSequence() - .filter { it.shouldPoll } - .mapTo(hashSetOf()) { SubscriptionKey(accountId = AccountId(it.groupAccountId), token = token) } + private fun parseAccountId(info: WorkInfo): AccountId? { + val tag = info.tags.firstOrNull { it.startsWith(ARG_ACCOUNT_ID) } ?: return null + val hex = tag.removePrefix(ARG_ACCOUNT_ID) + return AccountId.fromStringOrNull(hex) } - private data class SubscriptionKey(val accountId: AccountId, val token: String) -} \ No newline at end of file + private fun parseTokenFingerprint(info: WorkInfo): String? { + val tag = info.tags.firstOrNull { it.startsWith(ARG_TOKEN) } ?: return null + return tag.removePrefix(ARG_TOKEN) + } + + companion object { + private const val TAG = "PushRegistrationHandler" + + const val TAG_PERIODIC = "pn-register-periodic" + const val ARG_ACCOUNT_ID = "pn-account-" + const val ARG_TOKEN = "pn-token-" + + fun tokenFingerprint(token: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(token.toByteArray(Charsets.UTF_8)) + val short = digest.copyOfRange(0, 8) // 64 bits is plenty for equality checks + @Suppress("InlinedApi") + return android.util.Base64.encodeToString( + short, + android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt index f9c3810bbd..2122aaa359 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushRegistrationWorker.kt @@ -6,10 +6,12 @@ import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation +import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.await @@ -19,30 +21,41 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import network.loki.messenger.libsession_util.Namespace import org.session.libsession.database.userAuth +import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler.Companion.ARG_ACCOUNT_ID +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler.Companion.ARG_TOKEN +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler.Companion.TAG_PERIODIC +import org.thoughtcrime.securesms.notifications.PushRegistrationHandler.Companion.tokenFingerprint import java.time.Duration +import java.util.concurrent.TimeUnit @HiltWorker class PushRegistrationWorker @AssistedInject constructor( - @Assisted val context: Context, - @Assisted val params: WorkerParameters, - val registry: PushRegistryV2, - val storage: Storage, - val configFactory: ConfigFactory, + @Assisted private val context: Context, + @Assisted params: WorkerParameters, + private val tokenFetcher: TokenFetcher, // this is only used as a stale-token GUARD + private val storage: Storage, + private val configFactory: ConfigFactory, + private val registry: PushRegistryV2, ) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { - val accountId = checkNotNull(inputData.getString(ARG_ACCOUNT_ID) - ?.let(AccountId::fromStringOrNull)) { - "PushRegistrationWorker requires a valid account ID" - } - val token = checkNotNull(inputData.getString(ARG_TOKEN)) { - "PushRegistrationWorker requires a valid FCM token" + override suspend fun doWork(): Result { + val accountId = inputData.getString(ARG_ACCOUNT_ID)?.let(AccountId::fromStringOrNull) + ?: return Result.failure() + val token = inputData.getString(ARG_TOKEN) ?: return Result.failure() + + // Safety guard: if the current token changed, don't register the stale one. + tokenFetcher.token.value?.let { current -> + if (current.isNotEmpty() && current != token) { + Log.d(TAG, "Stale token for $accountId; skipping run.") + return Result.success() // no errors, we don't want to retry here + } } Log.d(TAG, "Registering push token for account: $accountId with token: ${token.substring(0..10)}") @@ -68,64 +81,81 @@ class PushRegistrationWorker @AssistedInject constructor( } } - try { + return try { registry.register(token = token, swarmAuth = swarmAuth, namespaces = namespaces) - Log.d(TAG, "Successfully registered push token for account: $accountId") - return Result.success() - } catch (e: CancellationException) { + Result.success() + } + catch (e: CancellationException) { Log.d(TAG, "Push registration cancelled for account: $accountId") throw e - } catch (e: Exception) { - Log.e(TAG, "Unexpected error while registering push token for account: $accountId", e) - return if (e is NonRetryableException) Result.failure() else Result.retry() + } + catch (e: NonRetryableException) { + Log.e(TAG, "Non retryable error while registering push token for account: $accountId", e) + Result.failure() + } + catch (_: Throwable){ + Log.e(TAG, "Error while registering push token for account: $accountId") + Result.retry() } } companion object { - private const val ARG_TOKEN = "token" - private const val ARG_ACCOUNT_ID = "account_id" - private const val TAG = "PushRegistrationWorker" - private val GROUP_PUSH_NAMESPACES = listOf( - Namespace.GROUP_MESSAGES(), - Namespace.GROUP_INFO(), - Namespace.GROUP_MEMBERS(), - Namespace.GROUP_KEYS(), - Namespace.REVOKED_GROUP_MESSAGES(), - ) - private val REGULAR_PUSH_NAMESPACES = listOf(Namespace.DEFAULT()) + private fun oneTimeName(id: AccountId) = "pn-register-once-${id.hexString}" + private fun periodicName(id: AccountId) = "pn-register-periodic-${id.hexString}" - private fun uniqueWorkName(accountId: AccountId): String { - return "push-registration-${accountId.hexString}" + suspend fun scheduleImmediate(context: Context, id: AccountId, token: String) { + val data = Data.Builder() + .putString(ARG_ACCOUNT_ID, id.hexString) + .putString(ARG_TOKEN, token) + .build() + val req = OneTimeWorkRequestBuilder() + .setInputData(data) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) + .setConstraints(Constraints(NetworkType.CONNECTED)) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(oneTimeName(id), ExistingWorkPolicy.REPLACE, req).await() } - fun schedule( - context: Context, - token: String, - accountId: AccountId, - ) { - val request = OneTimeWorkRequestBuilder() - .setInputData( - Data.Builder().putString(ARG_TOKEN, token) - .putString(ARG_ACCOUNT_ID, accountId.hexString).build() + suspend fun ensurePeriodic(context: Context, id: AccountId, token: String, replace: Boolean) { + val data = Data.Builder() + .putString(ARG_ACCOUNT_ID, id.hexString) + .putString(ARG_TOKEN, token) // immutable token snapshot + .build() + val req = PeriodicWorkRequestBuilder( + 7, TimeUnit.DAYS, + 1, TimeUnit.DAYS ) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) - .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) + .setInputData(data) + .addTag(TAG_PERIODIC) + .addTag(ARG_ACCOUNT_ID + id.hexString) + .addTag(ARG_TOKEN + tokenFingerprint(token)) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .build() - WorkManager.getInstance(context).enqueueUniqueWork( - uniqueWorkName = uniqueWorkName(accountId), - existingWorkPolicy = ExistingWorkPolicy.REPLACE, - request = request - ) + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + periodicName(id), + if (replace) ExistingPeriodicWorkPolicy.REPLACE else ExistingPeriodicWorkPolicy.KEEP, + req + ).await() } - suspend fun cancelRegistration(context: Context, accountId: AccountId) { - WorkManager.getInstance(context) - .cancelUniqueWork(uniqueWorkName(accountId)) - .await() + suspend fun cancelAll(context: Context, id: AccountId) { + val wm = WorkManager.getInstance(context) + wm.cancelUniqueWork(oneTimeName(id)).await() + wm.cancelUniqueWork(periodicName(id)).await() } } -} \ No newline at end of file +} + +private val GROUP_PUSH_NAMESPACES = listOf( + Namespace.GROUP_MESSAGES(), + Namespace.GROUP_INFO(), + Namespace.GROUP_MEMBERS(), + Namespace.GROUP_KEYS(), + Namespace.REVOKED_GROUP_MESSAGES(), +) +private val REGULAR_PUSH_NAMESPACES = listOf(Namespace.DEFAULT()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index ab436ab890..bb554ba913 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -30,17 +30,26 @@ import org.session.libsession.utilities.Util; import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.RecipientNamesKt; +import org.session.libsession.utilities.recipients.RemoteFile; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.util.AvatarUtils; -import org.thoughtcrime.securesms.util.BitmapUtil; import java.util.LinkedList; import java.util.List; import java.util.concurrent.ExecutionException; +import javax.inject.Provider; + +import coil3.BitmapImage; +import coil3.Image; +import coil3.ImageLoader; +import coil3.ImageLoaders; +import coil3.request.CachePolicy; +import coil3.request.ImageRequest; +import coil3.request.ImageResult; import network.loki.messenger.R; public class SingleRecipientNotificationBuilder extends AbstractNotificationBuilder { @@ -53,17 +62,20 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil private CharSequence contentTitle; private CharSequence contentText; private AvatarUtils avatarUtils; + private final Provider imageLoaderProvider; private static final Integer ICON_SIZE = 128; public SingleRecipientNotificationBuilder( @NonNull Context context, @NonNull NotificationPrivacyPreference privacy, - @NonNull AvatarUtils avatarUtils + @NonNull AvatarUtils avatarUtils, + Provider imageLoaderProvider ) { super(context, privacy); this.avatarUtils = avatarUtils; + this.imageLoaderProvider = imageLoaderProvider; setSmallIcon(R.drawable.ic_notification); setColor(ContextCompat.getColor(context, R.color.accent_green)); setCategory(NotificationCompat.CATEGORY_MESSAGE); @@ -72,34 +84,73 @@ public SingleRecipientNotificationBuilder( public void setThread(@NonNull Recipient recipient) { setChannelId(NotificationChannels.getMessagesChannel(context)); + Bitmap largeIconBitmap; + boolean recycleBitmap; + if (privacy.isDisplayContact()) { setContentTitle(RecipientNamesKt.displayName(recipient)); - Object avatar = recipient.getAvatar(); + RemoteFile avatar = recipient.getAvatar(); if (avatar != null) { try { - // AC: For some reason, if not use ".asBitmap()" method, the returned BitmapDrawable - // wraps a recycled bitmap and leads to a crash. - Bitmap iconBitmap = Glide.with(context.getApplicationContext()) - .asBitmap() - .load(avatar) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .circleCrop() - .submit(context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), - context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)) - .get(); - setLargeIcon(iconBitmap); - } catch (InterruptedException | ExecutionException e) { + int iconWidth = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width); + int iconHeight = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height); + + final ImageResult result = ImageLoaders.executeBlocking( + imageLoaderProvider.get(), + new ImageRequest.Builder(context) + .data(avatar) + .size(iconWidth, iconHeight) + .networkCachePolicy(CachePolicy.DISABLED) + .fallback(new BitmapImage(getPlaceholderDrawable(avatarUtils, recipient), true)) + .build() + ); + + Image image = result.getImage(); + + if (image instanceof BitmapImage) { + largeIconBitmap = ((BitmapImage) image).getBitmap(); + recycleBitmap = false; + } else if (image != null) { + // Generate a bitmap from this generic image by drawing on the bitmap + largeIconBitmap = Bitmap.createBitmap(iconWidth, iconHeight, Bitmap.Config.RGB_565); + image.draw(new Canvas(largeIconBitmap)); + recycleBitmap = true; + } else { + throw new IllegalStateException("No image returned from Coil"); + } + + } catch (Exception e) { Log.w(TAG, "get iconBitmap in getThread failed", e); - setLargeIcon(getPlaceholderDrawable(avatarUtils, recipient)); + largeIconBitmap = getPlaceholderDrawable(avatarUtils, recipient); + recycleBitmap = true; } } else { - setLargeIcon(getPlaceholderDrawable(avatarUtils, recipient)); + largeIconBitmap = getPlaceholderDrawable(avatarUtils, recipient); + recycleBitmap = true; } + setLargeIcon(getCircularBitmap(largeIconBitmap)); + if(recycleBitmap) largeIconBitmap.recycle(); + } else { setContentTitle(context.getString(R.string.app_name)); - setLargeIcon(avatarUtils.generateTextBitmap(ICON_SIZE, "", "Unknown")); + + Drawable drawable = ContextCompat.getDrawable(context, R.drawable.ic_user_filled_custom_padded); + int iconWidth = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width); + int iconHeight = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height); + + Bitmap src = Bitmap.createBitmap(iconWidth, iconHeight, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(src); + canvas.drawColor(context.getColor(R.color.classic_dark_3)); + + int padding = (int) (iconWidth * 0.08); //add some padding to the icon + drawable.setBounds(padding, padding, iconWidth - padding, iconHeight - padding); + drawable.draw(canvas); + + setLargeIcon(getCircularBitmap(src)); + setColor(context.getColor(R.color.classic_dark_3)); + src.recycle(); } } @@ -228,18 +279,14 @@ public Notification build() { return super.build(); } - private void setLargeIcon(@Nullable Drawable drawable) { - if (drawable != null) { - int largeIconTargetSize = context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size); - Bitmap recipientPhotoBitmap = BitmapUtil.createFromDrawable(drawable, largeIconTargetSize, largeIconTargetSize); + private Bitmap getCircularBitmap(Bitmap bitmap) { + boolean recycleInputBitmap = false; - if (recipientPhotoBitmap != null) { - setLargeIcon(getCircularBitmap(recipientPhotoBitmap)); - } + if (bitmap.getConfig() == Bitmap.Config.HARDWARE) { + bitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true); + recycleInputBitmap = true; } - } - private Bitmap getCircularBitmap(Bitmap bitmap) { final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); final Canvas canvas = new Canvas(output); final int color = Color.RED; @@ -254,7 +301,9 @@ private Bitmap getCircularBitmap(Bitmap bitmap) { paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); canvas.drawBitmap(bitmap, rect, rect, paint); - bitmap.recycle(); + if (recycleInputBitmap) { + bitmap.recycle(); + } return output; } @@ -314,7 +363,7 @@ private CharSequence getBigText(List messageBodies) { return content; } - private static Drawable getPlaceholderDrawable(AvatarUtils avatarUtils, Recipient recipient) { + private static Bitmap getPlaceholderDrawable(AvatarUtils avatarUtils, Recipient recipient) { String publicKey = recipient.getAddress().toString(); String displayName = RecipientNamesKt.displayName(recipient); return avatarUtils.generateTextBitmap(ICON_SIZE, publicKey, displayName); diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt deleted file mode 100644 index 6f0998eecb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ListPreferenceDialog.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.content.Context -import androidx.appcompat.app.AlertDialog -import androidx.preference.ListPreference -import org.thoughtcrime.securesms.showSessionDialog - -fun listPreferenceDialog( - context: Context, - listPreference: ListPreference, - onChange: () -> Unit -) : AlertDialog = listPreference.run { - context.showSessionDialog { - val index = entryValues.indexOf(value) - val options = entries.map(CharSequence::toString).toTypedArray() - - title(dialogTitle) - text(dialogMessage) - singleChoiceItems(options, index) { - listPreference.setValueIndex(it) - onChange() - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt deleted file mode 100644 index 1e0bcd27c0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt +++ /dev/null @@ -1,120 +0,0 @@ -package org.thoughtcrime.securesms.preferences - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import network.loki.messenger.R -import network.loki.messenger.databinding.ItemSelectableBinding -import com.bumptech.glide.Glide -import org.thoughtcrime.securesms.ui.GetString -import java.util.Objects - -class RadioOptionAdapter( - private var selectedOptionPosition: Int = 0, - private val onClickListener: (RadioOption) -> Unit -) : ListAdapter, RadioOptionAdapter.ViewHolder>(RadioOptionDiffer()) { - - class RadioOptionDiffer: DiffUtil.ItemCallback>() { - override fun areItemsTheSame(oldItem: RadioOption, newItem: RadioOption) = oldItem.title == newItem.title - override fun areContentsTheSame(oldItem: RadioOption, newItem: RadioOption) = Objects.equals(oldItem.value,newItem.value) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = - LayoutInflater.from(parent.context).inflate(R.layout.item_selectable, parent, false) - .let(::ViewHolder) - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind( - option = getItem(position), - isSelected = position == selectedOptionPosition - ) { - onClickListener(it) - setSelectedPosition(position) - } - } - - fun setSelectedPosition(selectedPosition: Int) { - notifyItemChanged(selectedOptionPosition) - selectedOptionPosition = selectedPosition - notifyItemChanged(selectedOptionPosition) - } - - class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) { - val glide = Glide.with(itemView) - val binding = ItemSelectableBinding.bind(itemView) - - fun bind(option: RadioOption, isSelected: Boolean, toggleSelection: (RadioOption) -> Unit) { - val alpha = if (option.enabled) 1f else 0.5f - binding.root.isEnabled = option.enabled - binding.root.contentDescription = option.contentDescription?.string(itemView.context) - binding.titleTextView.alpha = alpha - binding.subtitleTextView.alpha = alpha - binding.selectButton.alpha = alpha - - binding.titleTextView.text = option.title.string(itemView.context) - binding.subtitleTextView.text = option.subtitle?.string(itemView.context).also { - binding.subtitleTextView.isVisible = !it.isNullOrBlank() - } - - binding.selectButton.isSelected = isSelected - if (option.enabled) { - binding.root.setOnClickListener { toggleSelection(option) } - } - } - } -} - -data class RadioOption( - val value: T, - val title: GetString, - val subtitle: GetString? = null, - val enabled: Boolean = true, - val contentDescription: GetString? = null -) - -fun radioOption(value: T, @StringRes title: Int, configure: RadioOptionBuilder.() -> Unit = {}) = - radioOption(value, GetString(title), configure) - -fun radioOption(value: T, title: String, configure: RadioOptionBuilder.() -> Unit = {}) = - radioOption(value, GetString(title), configure) - -fun radioOption(value: T, title: GetString, configure: RadioOptionBuilder.() -> Unit = {}) = - RadioOptionBuilder(value, title).also { it.configure() }.build() - -class RadioOptionBuilder( - val value: T, - val title: GetString -) { - var subtitle: GetString? = null - var enabled: Boolean = true - var contentDescription: GetString? = null - - fun subtitle(string: String) { - subtitle = GetString(string) - } - - fun subtitle(@StringRes stringRes: Int) { - subtitle = GetString(stringRes) - } - - fun contentDescription(string: String) { - contentDescription = GetString(string) - } - - fun contentDescription(@StringRes stringRes: Int) { - contentDescription = GetString(stringRes) - } - - fun build() = RadioOption( - value, - title, - subtitle, - enabled, - contentDescription - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java deleted file mode 100644 index f24906c5e1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.service; - - -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -import org.session.libsignal.utilities.Log; - -public abstract class PersistentAlarmManagerListener extends BroadcastReceiver { - - private static final String TAG = PersistentAlarmManagerListener.class.getSimpleName(); - - protected abstract long getNextScheduledExecutionTime(Context context); - - protected abstract long onAlarm(Context context, long scheduledTime); - - @Override - public void onReceive(Context context, Intent intent) { - long scheduledTime = getNextScheduledExecutionTime(context); - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - Intent alarmIntent = new Intent(context, getClass()); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntent.FLAG_IMMUTABLE); - - if (System.currentTimeMillis() >= scheduledTime) { - scheduledTime = onAlarm(context, scheduledTime); - } - - Log.i(TAG, getClass() + " scheduling for: " + scheduledTime); - - alarmManager.cancel(pendingIntent); - alarmManager.set(AlarmManager.RTC_WAKEUP, scheduledTime, pendingIntent); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java b/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java deleted file mode 100644 index 09803bb793..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/QuickResponseService.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.thoughtcrime.securesms.service; - -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; - -import android.app.IntentService; -import android.content.Context; -import android.content.Intent; -import android.telephony.TelephonyManager; -import android.text.TextUtils; -import android.widget.Toast; -import com.squareup.phrase.Phrase; -import java.net.URISyntaxException; -import java.net.URLDecoder; -import network.loki.messenger.R; -import org.session.libsession.messaging.messages.visible.VisibleMessage; -import org.session.libsession.messaging.sending_receiving.MessageSender; -import org.session.libsession.snode.SnodeAPI; -import org.session.libsession.utilities.Address; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.util.Rfc5724Uri; - -public class QuickResponseService extends IntentService { - - private static final String TAG = QuickResponseService.class.getSimpleName(); - - public QuickResponseService() { - super("QuickResponseService"); - } - - @Override - protected void onHandleIntent(Intent intent) { - if (intent == null) { - Log.w(TAG, "Got null intent from QuickResponseService"); - return; - } - - String actionString = intent.getAction(); - if (actionString == null) { - Log.w(TAG, "Got null action from QuickResponseService intent"); - return; - } - - if (!TelephonyManager.ACTION_RESPOND_VIA_MESSAGE.equals(actionString)) { - Log.w(TAG, "Received unknown intent: " + intent.getAction()); - return; - } - - if (KeyCachingService.isLocked(this)) { - Log.w(TAG, "Got quick response request when locked..."); - Context c = getApplicationContext(); - String txt = Phrase.from(c, R.string.lockAppQuickResponse) - .put(APP_NAME_KEY, c.getString(R.string.app_name)) - .format().toString(); - Toast.makeText(this, txt, Toast.LENGTH_LONG).show(); - return; - } - - try { - Rfc5724Uri uri = new Rfc5724Uri(intent.getDataString()); - String content = intent.getStringExtra(Intent.EXTRA_TEXT); - String number = uri.getPath(); - - if (number.contains("%")){ - number = URLDecoder.decode(number); - } - - if (!TextUtils.isEmpty(content)) { - VisibleMessage message = new VisibleMessage(); - message.setText(content); - message.setSentTimestamp(SnodeAPI.getNowWithOffset()); - MessageSender.send(message, Address.fromSerialized(number)); - } - } catch (URISyntaxException e) { - Toast.makeText(this, R.string.errorUnknown, Toast.LENGTH_LONG).show(); - Log.w(TAG, e); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java deleted file mode 100644 index 85d00ec8e5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/service/UpdateApkReadyListener.java +++ /dev/null @@ -1,126 +0,0 @@ -package org.thoughtcrime.securesms.service; - -import static org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY; - -import android.app.DownloadManager; -import android.app.Notification; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import com.squareup.phrase.Phrase; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.security.MessageDigest; -import network.loki.messenger.R; -import org.session.libsession.utilities.FileUtils; -import org.session.libsession.utilities.ServiceUtil; -import org.session.libsession.utilities.TextSecurePreferences; -import org.session.libsignal.utilities.Hex; -import org.session.libsignal.utilities.Log; -import org.thoughtcrime.securesms.notifications.NotificationChannels; -import org.thoughtcrime.securesms.util.FileProviderUtil; - -public class UpdateApkReadyListener extends BroadcastReceiver { - - private static final String TAG = UpdateApkReadyListener.class.getSimpleName(); - - @Override - public void onReceive(Context context, Intent intent) { - Log.i(TAG, "onReceive()"); - - if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) { - long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2); - - if (downloadId == TextSecurePreferences.getUpdateApkDownloadId(context)) { - Uri uri = getLocalUriForDownloadId(context, downloadId); - String encodedDigest = TextSecurePreferences.getUpdateApkDigest(context); - - if (uri == null) { - Log.w(TAG, "Downloaded local URI is null?"); - return; - } - - if (isMatchingDigest(context, downloadId, encodedDigest)) { - displayInstallNotification(context, uri); - } else { - Log.w(TAG, "Downloaded APK doesn't match digest..."); - } - } - } - } - - private void displayInstallNotification(Context context, Uri uri) { - Intent intent = new Intent(Intent.ACTION_INSTALL_PACKAGE); - intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.setData(uri); - - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); - - CharSequence title = Phrase.from(context, R.string.updateSession) - .put(APP_NAME_KEY, context.getString(R.string.app_name)).format(); - - CharSequence txt = Phrase.from(context, R.string.updateNewVersion) - .put(APP_NAME_KEY, context.getString(R.string.app_name)).format(); - - - Notification notification = new NotificationCompat.Builder(context, NotificationChannels.APP_UPDATES) - .setOngoing(true) - .setContentTitle(title) - .setContentText(txt) - .setSmallIcon(R.drawable.ic_notification) - .setColor(context.getResources().getColor(R.color.textsecure_primary)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_REMINDER) - .setContentIntent(pendingIntent) - .build(); - - ServiceUtil.getNotificationManager(context).notify(666, notification); - } - - private @Nullable Uri getLocalUriForDownloadId(Context context, long downloadId) { - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - DownloadManager.Query query = new DownloadManager.Query(); - query.setFilterById(downloadId); - - Cursor cursor = downloadManager.query(query); - - try { - if (cursor != null && cursor.moveToFirst()) { - String localUri = cursor.getString(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI)); - - if (localUri != null) { - File localFile = new File(Uri.parse(localUri).getPath()); - return FileProviderUtil.getUriFor(context, localFile); - } - } - } finally { - if (cursor != null) cursor.close(); - } - - return null; - } - - private boolean isMatchingDigest(Context context, long downloadId, String theirEncodedDigest) { - try { - if (theirEncodedDigest == null) return false; - - byte[] theirDigest = Hex.fromStringCondensed(theirEncodedDigest); - DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); - FileInputStream fin = new FileInputStream(downloadManager.openDownloadedFile(downloadId).getFileDescriptor()); - byte[] ourDigest = FileUtils.getFileDigest(fin); - - fin.close(); - - return MessageDigest.isEqual(ourDigest, theirDigest); - } catch (IOException e) { - Log.w(TAG, e); - return false; - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index 12921f5f72..8a2b3e26bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -39,21 +39,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import com.google.zxing.BarcodeFormat import com.google.zxing.BinaryBitmap import com.google.zxing.ChecksumException +import com.google.zxing.DecodeHintType import com.google.zxing.FormatException import com.google.zxing.NotFoundException import com.google.zxing.PlanarYUVLuminanceSource -import com.google.zxing.Result +import com.google.zxing.common.GlobalHistogramBinarizer import com.google.zxing.common.HybridBinarizer import com.google.zxing.qrcode.QRCodeReader import com.squareup.phrase.Phrase @@ -255,38 +257,100 @@ class QRCodeAnalyzer( private val onBarcodeScanned: (String) -> Unit ): ImageAnalysis.Analyzer { - // Note: This analyze method is called once per frame of the camera feed. @SuppressLint("UnsafeOptInUsageError") override fun analyze(image: ImageProxy) { - // Grab the image data as a byte array so we can generate a PlanarYUVLuminanceSource from it - val buffer = image.planes[0].buffer - buffer.rewind() - val imageBytes = ByteArray(buffer.capacity()) - buffer.get(imageBytes) // IMPORTANT: This transfers data from the buffer INTO the imageBytes array, although it looks like it would go the other way around! + try { + // Visible frame size that ZXing will use for decoding + val w = image.width + val h = image.height - // ZXing requires data as a BinaryBitmap to scan for QR codes, and to generate that we need to feed it a PlanarYUVLuminanceSource - val luminanceSource = PlanarYUVLuminanceSource(imageBytes, image.width, image.height, 0, 0, image.width, image.height, false) - val binaryBitmap = BinaryBitmap(HybridBinarizer(luminanceSource)) + // YUV_420_888 format: plane[0] = Y (grayscale), plane[1] = U, plane[2] = V + // ZXing only needs luminance (Y) + val yPlane = image.planes[0] - // Attempt to extract a QR code from the binary bitmap, and pass it through to our `onBarcodeScanned` method if we find one - try { - val result: Result = qrCodeReader.decode(binaryBitmap) - val resultTxt = result.text - // No need to close the image here - it'll always make it to the end, and calling `onBarcodeScanned` - // with a valid contact / recovery phrase / community code will stop calling this `analyze` method. - onBarcodeScanned(resultTxt) - } - catch (nfe: NotFoundException) { /* Hits if there is no QR code in the image */ } - catch (fe: FormatException) { /* Hits if we found a QR code but failed to decode it */ } - catch (ce: ChecksumException) { /* Hits if we found a QR code which is corrupted */ } - catch (e: Exception) { - // Hits if there's a genuine problem - Log.e("QR", "error", e) - } + // Strides describe how bytes are laid out in memory for this plane + // - rowStride: distance in bytes from start of one row to the next row + // - pixelStride: distance in bytes from one pixel to the next pixel in the same row + // Usually 1 for Y (packed), but not guaranteed across devices + val rowStride = yPlane.rowStride + val pixelStride = yPlane.pixelStride + + val buf = yPlane.buffer + buf.rewind() + + // ZXing wants a contiguous WxH grayscale buffer (one byte per pixel) + val y = ByteArray(w * h) + + // FAST PATH: already tightly packed (no row padding, no interleaving) + if (pixelStride == 1 && rowStride == w) { + // We can copy the entire Y plane in a single read + buf.get(y, 0, y.size) + } else { + // GENERAL PATH: re-pack into contiguous WxH + // We use a duplicate buffer so we can manipulate position/absolute reads + // without affecting the original buffer state elsewhere + val dup = buf.duplicate() + + var dst = 0 // index we write into in the output array 'y' + + // Walk row by row in the source plane + for (row in 0 until h) { + // Start of this row in the plane's buffer + val rowStart = row * rowStride + + if (pixelStride == 1) { + // Case A: packed pixels (good), but rows have padding (rowStride > w) + // Copy only the first 'w' bytes of each row into our contiguous output + dup.position(rowStart) + dup.get(y, dst, w) + dst += w + } else { + // Case B: pixels are interleaved horizontally (pixelStride > 1) + // Read one luminance byte every 'pixelStride' bytes for 'w' columns + for (col in 0 until w) { + // Absolute read: get byte at (rowStart + col*pixelStride) without + // changing buffer's position. This picks each pixel's Y byte + y[dst++] = dup.get(rowStart + col * pixelStride) + } + } + } + } + + // Build a source from a contiguous Y plane (no rotation) + val base = PlanarYUVLuminanceSource( + y, w, h, 0, 0, w, h, false + ) + + val hints = java.util.EnumMap(DecodeHintType::class.java).apply { + put(DecodeHintType.TRY_HARDER, true) + put(DecodeHintType.POSSIBLE_FORMATS, listOf(BarcodeFormat.QR_CODE)) + } - // Remember to close the image when we're done with it! - // IMPORTANT: It is CLOSING the image that allows this method to run again! If we don't - // close the image this method runs precisely ONCE and that's it, which is essentially useless. - image.close() + val attempts = listOf( + BinaryBitmap(HybridBinarizer(base)), + BinaryBitmap(GlobalHistogramBinarizer(base)), + BinaryBitmap(HybridBinarizer(com.google.zxing.InvertedLuminanceSource(base))), + BinaryBitmap(GlobalHistogramBinarizer(com.google.zxing.InvertedLuminanceSource(base))) + ) + + for (bb in attempts) { + try { + val result = qrCodeReader.decode(bb, hints) + onBarcodeScanned(result.text) + return + } catch (_: NotFoundException) { + qrCodeReader.reset() // harmless, move to next attempt + } + } + } catch (e: FormatException) { + Log.e("QR", "QR decoding failed", e) + } catch (e: ChecksumException) { + Log.e("QR", "QR checksum exception", e) + } catch (e: Exception) { + Log.e("QR", "Analyzer error", e) + } finally { + qrCodeReader.reset() + image.close() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt index db69bd8cc4..a978a65e39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtils.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.util import android.content.Context +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect @@ -28,6 +29,7 @@ import org.session.libsession.utilities.recipients.displayName import org.session.libsignal.utilities.IdPrefix import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.theme.classicDark3 import java.math.BigInteger import java.security.MessageDigest import java.util.Locale @@ -101,7 +103,13 @@ class AvatarUtils @Inject constructor( // custom image val (remoteFile, customIcon, color) = when { // use custom image if there is one - recipient.avatar != null -> Triple(recipient.avatar!!, null, defaultColor) + recipient.avatar != null -> Triple( + recipient.avatar!!, + // for communities, have an icon fallback in case the image errors out + if(recipient.isCommunityRecipient) R.drawable.session_logo else null, + // communities should always have a neutral fallback bg + if(recipient.isCommunityRecipient) classicDark3 else defaultColor + ) // communities without a custom image should use a default image recipient.isCommunityRecipient -> Triple(null, R.drawable.session_logo, null) @@ -128,7 +136,7 @@ class AvatarUtils @Inject constructor( return avatarBgColors[(hash % avatarBgColors.size).toInt()] } - fun generateTextBitmap(pixelSize: Int, hashString: String, displayName: String?): BitmapDrawable { + fun generateTextBitmap(pixelSize: Int, hashString: String, displayName: String?): Bitmap { val colorPrimary = getColorFromKey(hashString) val labelText = when { @@ -158,7 +166,7 @@ class AvatarUtils @Inject constructor( textBounds.top += (areaRect.height() - textBounds.bottom) * 0.5f canvas.drawText(labelText, textBounds.left, textBounds.top - textPaint.ascent(), textPaint) - return BitmapDrawable(context.resources, bitmap) + return bitmap } private fun getSha512(input: String): String { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt index dbd8da93d9..4baecaf6e2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ClearDataUtils.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Application import android.content.Intent import androidx.core.content.edit +import androidx.work.WorkManager import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -49,16 +50,19 @@ class ClearDataUtils @Inject constructor( application.deleteDatabase(DatabaseMigrationManager.CIPHER4_DB_NAME) application.deleteDatabase(DatabaseMigrationManager.CIPHER3_DB_NAME) - TextSecurePreferences.clearAll(application) + // clear all prefs + prefs.clearAll() + application.getSharedPreferences(ApplicationContext.PREFERENCES_NAME, 0).edit(commit = true) { clear() } application.cacheDir.deleteRecursively() application.filesDir.deleteRecursively() configFactory.clearAll() - RemoteFileDownloadWorker.cancelAll(application) - persistentLogger.deleteAllLogs() + // clean up existing work manager + WorkManager.getInstance(application).cancelAllWork() + // The token deletion is nice but not critical, so don't let it block the rest of the process runCatching { tokenFetcher.resetToken() diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt deleted file mode 100644 index f228eb57a4..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ContentResolverUtils.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.content.ContentResolver -import android.database.ContentObserver -import android.net.Uri -import android.os.Handler -import android.os.Looper -import androidx.annotation.CheckResult -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow - -/** - * Observe changes to a content Uri. This function will emit the Uri whenever the content or - * its descendants change, according to the parameter [notifyForDescendants]. - */ -@CheckResult -fun ContentResolver.observeChanges(uri: Uri, notifyForDescendants: Boolean = false): Flow { - return callbackFlow { - val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean) { - trySend(uri) - } - } - - registerContentObserver(uri, notifyForDescendants, observer) - awaitClose { - unregisterContentObserver(observer) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ListUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ListUtil.java deleted file mode 100644 index 321a3abfc0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ListUtil.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import androidx.annotation.NonNull; - -import java.util.ArrayList; -import java.util.List; - -public final class ListUtil { - private ListUtil() {} - - public static List> chunk(@NonNull List list, int chunkSize) { - List> chunks = new ArrayList<>(list.size() / chunkSize); - - for (int i = 0; i < list.size(); i += chunkSize) { - List chunk = list.subList(i, Math.min(list.size(), i + chunkSize)); - chunks.add(chunk); - } - - return chunks; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java deleted file mode 100644 index f193827efd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileUtil.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.thoughtcrime.securesms.util; - - -import android.os.MemoryFile; -import android.os.ParcelFileDescriptor; - -import java.io.FileDescriptor; -import java.io.IOException; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; - -public class MemoryFileUtil { - - public static ParcelFileDescriptor getParcelFileDescriptor(MemoryFile file) throws IOException { - try { - Method method = MemoryFile.class.getDeclaredMethod("getFileDescriptor"); - FileDescriptor fileDescriptor = (FileDescriptor) method.invoke(file); - - Field field = fileDescriptor.getClass().getDeclaredField("descriptor"); - field.setAccessible(true); - - int fd = field.getInt(fileDescriptor); - - return ParcelFileDescriptor.fromFd(fd); - } catch (IllegalAccessException e) { - throw new IOException(e); - } catch (InvocationTargetException e) { - throw new IOException(e); - } catch (NoSuchMethodException e) { - throw new IOException(e); - } catch (NoSuchFieldException e) { - throw new IOException(e); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PointFUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/PointFUtilities.kt deleted file mode 100644 index 2cbe20aa00..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/PointFUtilities.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.graphics.PointF -import android.view.View -import org.thoughtcrime.securesms.util.hitRect - -fun PointF.distanceTo(other: PointF): Float { - return Math.sqrt(Math.pow(this.x.toDouble() - other.x.toDouble(), 2.toDouble()) + Math.pow(this.y.toDouble() - other.y.toDouble(), 2.toDouble())).toFloat() -} - -fun PointF.isLeftOf(view: View, margin: Float = 0.0f): Boolean { - return isContainedVerticallyIn(view, margin) && x < view.hitRect.left -} - -fun PointF.isAbove(view: View, margin: Float = 0.0f): Boolean { - return isContainedHorizontallyIn(view, margin) && y < view.hitRect.top -} - -fun PointF.isRightOf(view: View, margin: Float = 0.0f): Boolean { - return isContainedVerticallyIn(view, margin) && x > view.hitRect.right -} - -fun PointF.isBelow(view: View, margin: Float = 0.0f): Boolean { - return isContainedHorizontallyIn(view, margin) && y > view.hitRect.bottom -} - -fun PointF.isContainedHorizontallyIn(view: View, margin: Float = 0.0f): Boolean { - return x >= view.hitRect.left - margin || x <= view.hitRect.right + margin -} - -fun PointF.isContainedVerticallyIn(view: View, margin: Float = 0.0f): Boolean { - return y >= view.hitRect.top - margin || x <= view.hitRect.bottom + margin -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Rfc5724Uri.java b/app/src/main/java/org/thoughtcrime/securesms/util/Rfc5724Uri.java deleted file mode 100644 index deba6b6516..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Rfc5724Uri.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (C) 2015 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.thoughtcrime.securesms.util; - -import java.net.URISyntaxException; -import java.net.URLDecoder; -import java.util.HashMap; -import java.util.Map; - -public class Rfc5724Uri { - - private final String uri; - private final String schema; - private final String path; - private final Map queryParams; - - public Rfc5724Uri(String uri) throws URISyntaxException { - this.uri = uri; - this.schema = parseSchema(); - this.path = parsePath(); - this.queryParams = parseQueryParams(); - } - - private String parseSchema() throws URISyntaxException { - String[] parts = uri.split(":"); - - if (parts.length < 1 || parts[0].isEmpty()) throw new URISyntaxException(uri, "invalid schema"); - else return parts[0]; - } - - private String parsePath() throws URISyntaxException { - String[] parts = uri.split("\\?")[0].split(":", 2); - - if (parts.length < 2 || parts[1].isEmpty()) throw new URISyntaxException(uri, "invalid path"); - else return parts[1]; - } - - private Map parseQueryParams() throws URISyntaxException { - Map queryParams = new HashMap<>(); - if (uri.split("\\?").length < 2) { - return queryParams; - } - - for (String keyValue : uri.split("\\?")[1].split("&")) { - String[] parts = keyValue.split("="); - - if (parts.length == 1) queryParams.put(parts[0], ""); - else queryParams.put(parts[0], URLDecoder.decode(parts[1])); - } - - return queryParams; - } - - public String getSchema() { - return schema; - } - - public String getPath() { - return path; - } - - public Map getQueryParams() { - return queryParams; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.kt deleted file mode 100644 index f7a2a7482f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SqlUtil.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.thoughtcrime.securesms.util - -import androidx.annotation.VisibleForTesting -import com.annimon.stream.Collectors -import com.annimon.stream.Stream - -object SqlUtil { - /** The maximum number of arguments (i.e. question marks) allowed in a SQL statement. */ - private const val MAX_QUERY_ARGS = 999 - - @JvmStatic - fun buildArgs(vararg objects: Any?): Array { - return objects.map { - when (it) { - null -> throw NullPointerException("Cannot have null arg!") - else -> it.toString() - } - }.toTypedArray() - } - - @JvmStatic - fun buildArgs(argument: Long): Array { - return arrayOf(argument.toString()) - } - - /** - * A convenient way of making queries in the form: WHERE [column] IN (?, ?, ..., ?) - * Handles breaking it - */ - @JvmStatic - fun buildCollectionQuery(column: String, values: Collection): List { - return buildCollectionQuery(column, values, MAX_QUERY_ARGS) - } - - @VisibleForTesting - @JvmStatic - fun buildCollectionQuery(column: String, values: Collection, maxSize: Int): List { - require(!values.isEmpty()) { "Must have values!" } - - return values - .chunked(maxSize) - .map { batch -> buildSingleCollectionQuery(column, batch) } - } - - /** - * A convenient way of making queries in the form: WHERE [column] IN (?, ?, ..., ?) - * - * Important: Should only be used if you know the number of values is < 1000. Otherwise you risk creating a SQL statement this is too large. - * Prefer [buildCollectionQuery] when possible. - */ - @JvmStatic - fun buildSingleCollectionQuery(column: String, values: Collection): Query { - require(!values.isEmpty()) { "Must have values!" } - - val query = StringBuilder() - val args = arrayOfNulls(values.size) - - for ((i, value) in values.withIndex()) { - query.append("?") - args[i] = value - if (i != values.size - 1) { - query.append(", ") - } - } - return Query("$column IN ($query)", buildArgs(*args)) - } - - @JvmStatic - fun buildCustomCollectionQuery(query: String, argList: List>): List { - return buildCustomCollectionQuery(query, argList, MAX_QUERY_ARGS) - } - - @JvmStatic - @VisibleForTesting - fun buildCustomCollectionQuery(query: String, argList: List>, maxQueryArgs: Int): List { - val batchSize: Int = maxQueryArgs / argList[0].size - return Stream.of(ListUtil.chunk(argList, batchSize)) - .map { argBatch -> buildSingleCustomCollectionQuery(query, argBatch) } - .collect(Collectors.toList()) - } - - private fun buildSingleCustomCollectionQuery(query: String, argList: List>): Query { - val outputQuery = StringBuilder() - val outputArgs: MutableList = mutableListOf() - - var i = 0 - val len = argList.size - - while (i < len) { - outputQuery.append("(").append(query).append(")") - if (i < len - 1) { - outputQuery.append(" OR ") - } - - val args = argList[i] - for (arg in args) { - outputArgs += arg - } - - i++ - } - - return Query(outputQuery.toString(), outputArgs.toTypedArray()) - } - - class Query(val where: String, val whereArgs: Array) -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java b/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java deleted file mode 100644 index 0afe0192c0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StickyHeaderDecoration.java +++ /dev/null @@ -1,204 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import android.graphics.Canvas; -import android.graphics.Rect; -import android.os.Build.VERSION; -import androidx.annotation.NonNull; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; -import android.view.View; -import android.view.ViewGroup; - -import java.util.HashMap; -import java.util.Map; - -/** - * A sticky header decoration for android's RecyclerView. - * Currently only supports LinearLayoutManager in VERTICAL orientation. - */ -public class StickyHeaderDecoration extends RecyclerView.ItemDecoration { - - private static final String TAG = StickyHeaderDecoration.class.getSimpleName(); - - private static final long NO_HEADER_ID = -1L; - - private final Map headerCache; - private final StickyHeaderAdapter adapter; - private final boolean renderInline; - private boolean sticky; - - /** - * @param adapter the sticky header adapter to use - */ - public StickyHeaderDecoration(StickyHeaderAdapter adapter, boolean renderInline, boolean sticky) { - this.adapter = adapter; - this.headerCache = new HashMap<>(); - this.renderInline = renderInline; - this.sticky = sticky; - } - - /** - * {@inheritDoc} - */ - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, - @NonNull RecyclerView.State state) - { - int position = parent.getChildAdapterPosition(view); - int headerHeight = 0; - - if (position != RecyclerView.NO_POSITION && hasHeader(parent, adapter, position)) { - View header = getHeader(parent, adapter, position).itemView; - headerHeight = getHeaderHeightForLayout(header); - } - - outRect.set(0, headerHeight, 0, 0); - } - - protected boolean hasHeader(RecyclerView parent, StickyHeaderAdapter adapter, int adapterPos) { - boolean isReverse = isReverseLayout(parent); - int itemCount = ((RecyclerView.Adapter)adapter).getItemCount(); - - if ((isReverse && adapterPos == itemCount - 1 && adapter.getHeaderId(adapterPos) != -1) || - (!isReverse && adapterPos == 0)) - { - return true; - } - - int previous = adapterPos + (isReverse ? 1 : -1); - long headerId = adapter.getHeaderId(adapterPos); - long previousHeaderId = adapter.getHeaderId(previous); - - return headerId != NO_HEADER_ID && previousHeaderId != NO_HEADER_ID && headerId != previousHeaderId; - } - - protected ViewHolder getHeader(RecyclerView parent, StickyHeaderAdapter adapter, int position) { - final long key = adapter.getHeaderId(position); - - ViewHolder headerHolder = headerCache.get(key); - if (headerHolder == null) { - headerHolder = adapter.onCreateHeaderViewHolder(parent); - - //noinspection unchecked - adapter.onBindHeaderViewHolder(headerHolder, position); - - headerCache.put(key, headerHolder); - } - - final View header = headerHolder.itemView; - - int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); - int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED); - - int childWidth = ViewGroup.getChildMeasureSpec(widthSpec, - parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width); - int childHeight = ViewGroup.getChildMeasureSpec(heightSpec, - parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height); - - header.measure(childWidth, childHeight); - header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight()); - - return headerHolder; - } - - /** - * {@inheritDoc} - */ - @Override - public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { - final int count = parent.getChildCount(); - - for (int layoutPos = 0; layoutPos < count; layoutPos++) { - final View child = parent.getChildAt(translatedChildPosition(parent, layoutPos)); - - final int adapterPos = parent.getChildAdapterPosition(child); - - if (adapterPos != RecyclerView.NO_POSITION && ((layoutPos == 0 && sticky) || hasHeader(parent, adapter, adapterPos))) { - View header = getHeader(parent, adapter, adapterPos).itemView; - c.save(); - final int left = child.getLeft(); - final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos); - c.translate(left, top); - header.draw(c); - c.restore(); - } - } - } - - protected int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, - int layoutPos) - { - int headerHeight = getHeaderHeightForLayout(header); - int top = (int)child.getY() - headerHeight; - if (sticky && layoutPos == 0) { - final int count = parent.getChildCount(); - final long currentId = adapter.getHeaderId(adapterPos); - // find next view with header and compute the offscreen push if needed - for (int i = 1; i < count; i++) { - int adapterPosHere = parent.getChildAdapterPosition(parent.getChildAt(translatedChildPosition(parent, i))); - if (adapterPosHere != RecyclerView.NO_POSITION) { - long nextId = adapter.getHeaderId(adapterPosHere); - if (nextId != currentId) { - final View next = parent.getChildAt(translatedChildPosition(parent, i)); - final int offset = (int)next.getY() - (headerHeight + getHeader(parent, adapter, adapterPosHere).itemView.getHeight()); - if (offset < 0) { - return offset; - } else { - break; - } - } - } - } - - if (sticky) top = Math.max(0, top); - } - - return top; - } - - private int translatedChildPosition(RecyclerView parent, int position) { - return isReverseLayout(parent) ? parent.getChildCount() - 1 - position : position; - } - - protected int getHeaderHeightForLayout(View header) { - return renderInline ? 0 : header.getHeight(); - } - - private boolean isReverseLayout(final RecyclerView parent) { - return (parent.getLayoutManager() instanceof LinearLayoutManager) && - ((LinearLayoutManager)parent.getLayoutManager()).getReverseLayout(); - } - - /** - * The adapter to assist the {@link StickyHeaderDecoration} in creating and binding the header views. - * - * @param the header view holder - */ - public interface StickyHeaderAdapter { - - /** - * Returns the header id for the item at the given position. - * - * @param position the item position - * @return the header id - */ - long getHeaderId(int position); - - /** - * Creates a new header ViewHolder. - * - * @param parent the header's view parent - * @return a view holder for the created view - */ - T onCreateHeaderViewHolder(ViewGroup parent); - - /** - * Updates the header view to reflect the header data for the given position - * @param viewHolder the header view holder - * @param position the header's item position - */ - void onBindHeaderViewHolder(T viewHolder, int position); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java index 1f32748155..cfd0b64960 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StreamUtil.java @@ -30,52 +30,6 @@ public static void close(@Nullable Closeable closeable) { } } - public static long getStreamLength(InputStream in) throws IOException { - byte[] buffer = new byte[4096]; - int totalSize = 0; - - int read; - - while ((read = in.read(buffer)) != -1) { - totalSize += read; - } - - return totalSize; - } - - public static void readFully(InputStream in, byte[] buffer) throws IOException { - readFully(in, buffer, buffer.length); - } - - public static void readFully(InputStream in, byte[] buffer, int len) throws IOException { - int offset = 0; - - for (;;) { - int read = in.read(buffer, offset, len - offset); - if (read == -1) throw new EOFException("Stream ended early"); - - if (read + offset < len) offset += read; - else return; - } - } - - public static byte[] readFully(InputStream in) throws IOException { - ByteArrayOutputStream bout = new ByteArrayOutputStream(); - byte[] buffer = new byte[4096]; - int read; - - while ((read = in.read(buffer)) != -1) { - bout.write(buffer, 0, read); - } - - in.close(); - - return bout.toByteArray(); - } - - public static String readFullyAsString(InputStream in) throws IOException { - return new String(readFully(in)); - } public static long copy(InputStream in, OutputStream out) throws IOException { byte[] buffer = new byte[64 * 1024]; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index 97f1257b28..5f1291ceb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -20,8 +20,6 @@ import android.content.Context; import android.text.TextUtils; -import org.thoughtcrime.securesms.components.ComposeText; - import network.loki.messenger.BuildConfig; public class Util { @@ -31,10 +29,6 @@ public static boolean isLowMemory(Context context) { return (activityManager.isLowRamDevice()) || activityManager.getLargeMemoryClass() <= 64; } - public static boolean isEmpty(ComposeText value) { - return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed()); - } - public static int getCanonicalVersionCode() { return BuildConfig.CANONICAL_VERSION_CODE; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 4c275ca886..7b965f2e3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -46,24 +46,6 @@ val View.hitRect: Rect @ColorInt fun Context.getAccentColor() = getColorFromAttr(R.attr.colorAccent) -// Method to grab the appropriate attribute for a message colour. -// Note: This is an attribute, NOT a resource Id - see `getColorResourceIdFromAttr` for that. -@AttrRes -fun getMessageTextColourAttr(messageIsOutgoing: Boolean): Int { - return if (messageIsOutgoing) R.attr.message_sent_text_color else R.attr.message_received_text_color -} - -// Method to get an actual R.id. resource Id from an attribute such as R.attr.message_sent_text_color etc. -@ColorRes -fun getColorResourceIdFromAttr(context: Context, attr: Int): Int { - val outTypedValue = TypedValue() - val successfullyFoundAttribute = context.theme.resolveAttribute(attr, outTypedValue, true) - if (successfullyFoundAttribute) { return outTypedValue.resourceId } - - Log.w("ViewUtils", "Could not find colour attribute $attr in theme - using grey as a safe fallback") - return R.color.gray50 -} - fun View.animateSizeChange(@DimenRes startSizeID: Int, @DimenRes endSizeID: Int, animationDuration: Long = 250) { val startSize = resources.getDimension(startSizeID) val endSize = resources.getDimension(endSizeID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewUtils.kt index b6304f463b..aebd64a222 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewUtils.kt @@ -1,24 +1,9 @@ package org.thoughtcrime.securesms.util.adapter -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.view.doOnPreDraw -import androidx.core.view.updatePadding import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView -import kotlin.math.max - -// Makes sure that the recyclerView is scrolled to the bottom -fun RecyclerView.applyImeBottomPadding() { - clipToPadding = false - ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets -> - val system = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom - val ime = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom - v.updatePadding(bottom = max(system, ime)) - insets - } -} // Handle scroll logic fun RecyclerView.handleScrollToBottom(fastScroll: Boolean = false) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SelectableItem.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SelectableItem.kt deleted file mode 100644 index 88b41d11cd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/SelectableItem.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.thoughtcrime.securesms.util.adapter - -data class SelectableItem(val item: T, val isSelected: Boolean) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt index c6dd6525e9..1171fc190b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/AudioManagerCommand.kt @@ -26,7 +26,7 @@ open class AudioManagerCommand: Parcelable { data class Stop(val playDisconnect: Boolean): AudioManagerCommand() @Parcelize - data class StartIncomingRinger(val vibrate: Boolean): AudioManagerCommand() + data object StartIncomingRinger: AudioManagerCommand() @Parcelize data class SetUserDevice(val device: SignalAudioManager.AudioDevice): AudioManagerCommand() diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 62be057c74..0fb4d6755b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -823,7 +823,7 @@ class CallManager @Inject constructor( } fun startIncomingRinger() { - signalAudioManager.handleCommand(AudioManagerCommand.StartIncomingRinger(true)) + signalAudioManager.handleCommand(AudioManagerCommand.StartIncomingRinger) } fun startCommunication() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt index 155e02e6a2..9ed60cbdd5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/WebRtcCallBridge.kt @@ -418,22 +418,28 @@ class WebRtcCallBridge @Inject constructor( fun handleAnswerIncoming(address: Address, sdp: String, callId: UUID) { serviceExecutor.execute { - try { - if (address.isLocalNumber && callManager.currentConnectionState in CallState.CAN_DECLINE_STATES) { - handleLocalHangup(address) - return@execute + val state = callManager.currentConnectionState + val isInitiator = callManager.isInitiator() + + // If we receive a self-synced ANSWER: + if (address.isLocalNumber) { + // Only act if this device was in an INCOMING ring state (answered elsewhere). + if (!isInitiator && state in arrayOf(CallState.RemotePreOffer, CallState.RemoteRing)) { + // Stop ringing / update UI, but DO NOT hang up the remote. + callManager.silenceIncomingRinger() + callManager.handleIgnoreCall() + terminate() + } else { + // We’re the caller or already past ring → ignore self-answer + Log.w(TAG, "Ignoring self-synced ANSWER in state=$state (isInitiator=$isInitiator)") } - - callManager.postViewModelState(CallViewModel.State.CALL_ANSWER_OUTGOING) - - callManager.handleResponseMessage( - address, - callId, - SessionDescription(SessionDescription.Type.ANSWER, sdp) - ) - } catch (e: PeerConnectionException) { - terminate() + return@execute } + + callManager.postViewModelState(CallViewModel.State.CALL_ANSWER_OUTGOING) + callManager.handleResponseMessage( + address, callId, SessionDescription(SessionDescription.Type.ANSWER, sdp) + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt index 1b3995a2aa..36a4658ed5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/IncomingRinger.kt @@ -1,10 +1,16 @@ package org.thoughtcrime.securesms.webrtc.audio +import android.app.NotificationManager import android.content.Context +import android.media.AudioAttributes import android.media.AudioManager import android.media.MediaPlayer import android.media.RingtoneManager +import android.os.Build +import android.os.VibrationAttributes +import android.os.VibrationEffect import android.os.Vibrator +import android.os.VibratorManager import org.session.libsession.utilities.ServiceUtil import org.session.libsignal.utilities.Log @@ -14,56 +20,89 @@ class IncomingRinger(private val context: Context) { val PATTERN = longArrayOf(0L, 1000L, 1000L) } - private val vibrator: Vibrator? = ServiceUtil.getVibrator(context) var mediaPlayer: MediaPlayer? = null val isRinging: Boolean get() = mediaPlayer?.isPlaying ?: false - fun start(vibrate: Boolean) { + fun start() { val audioManager = ServiceUtil.getAudioManager(context) mediaPlayer?.release() mediaPlayer = createMediaPlayer() - val ringerMode = audioManager.ringerMode - if (shouldVibrate(mediaPlayer, ringerMode, vibrate)) { - Log.i(TAG,"Starting vibration") - vibrator?.vibrate(PATTERN, 1) - } else { - Log.i(TAG,"Skipping vibration") - } + // Vibrate if policy/system allows + if (shouldVibrate(audioManager)) vibrate() + // Play ringtone only in NORMAL mediaPlayer?.let { player -> - if (ringerMode == AudioManager.RINGER_MODE_NORMAL) { + if (audioManager.ringerMode == AudioManager.RINGER_MODE_NORMAL) { try { if (!player.isPlaying) { player.prepare() player.start() - Log.i(TAG,"Playing ringtone") + Log.i(TAG, "Playing ringtone") } } catch (e: Exception) { - Log.e(TAG,"Failed to start mediaPlayer", e) + Log.e(TAG, "Failed to start mediaPlayer", e) } } } ?: run { - Log.w(TAG,"Not ringing, mediaPlayer: ${mediaPlayer?.let{"available"}}, mode: $ringerMode") + Log.w(TAG,"Not ringing, mediaPlayer: ${mediaPlayer?.let{"available"}}") } - } fun stop() { mediaPlayer?.release() mediaPlayer = null - vibrator?.cancel() + if (Build.VERSION.SDK_INT >= 31) { + context.getSystemService(VibratorManager::class.java) + ?.defaultVibrator?.cancel() + } else { + context.getSystemService(Vibrator::class.java)?.cancel() + } } - private fun shouldVibrate(player: MediaPlayer?, ringerMode: Int, vibrate: Boolean): Boolean { - player ?: return true + private fun shouldVibrate(audioManager: AudioManager): Boolean { + val v = ServiceUtil.getVibrator(context) ?: return false + if (!v.hasVibrator()) return false + + // Respect 'Do Not Disturb' + val nm = context.getSystemService(NotificationManager::class.java) + when (nm?.currentInterruptionFilter) { + NotificationManager.INTERRUPTION_FILTER_NONE, + NotificationManager.INTERRUPTION_FILTER_ALARMS -> return false + } + + return when (audioManager.ringerMode) { + AudioManager.RINGER_MODE_SILENT -> false + AudioManager.RINGER_MODE_VIBRATE -> true + AudioManager.RINGER_MODE_NORMAL -> true + else -> false + } + } - if (vibrator == null || !vibrator.hasVibrator()) return false + private fun vibrate() { + if (Build.VERSION.SDK_INT >= 31) { + val vm = context.getSystemService(VibratorManager::class.java) ?: return + val v = vm.defaultVibrator + if (!v.hasVibrator()) return + + val effect = VibrationEffect.createWaveform(PATTERN, 1) + if (Build.VERSION.SDK_INT >= 33) { + val attrs = VibrationAttributes.Builder() + .setUsage(VibrationAttributes.USAGE_RINGTONE) + .build() + v.vibrate(effect, attrs) + } else { + v.vibrate(effect) + } + } else { + val v = context.getSystemService(Vibrator::class.java) ?: return + if (!v.hasVibrator()) return - return if (vibrate) ringerMode != AudioManager.RINGER_MODE_SILENT - else ringerMode == AudioManager.RINGER_MODE_VIBRATE + val effect = VibrationEffect.createWaveform(PATTERN, 1) + v.vibrate(effect) + } } private fun createMediaPlayer(): MediaPlayer? { @@ -76,9 +115,17 @@ class IncomingRinger(private val context: Context) { } ?: return null try { - val mediaPlayer = MediaPlayer() - mediaPlayer.setDataSource(context, defaultRingtone) - return mediaPlayer + return MediaPlayer().apply { + // Make volume follow the "Ring & notification" slider + setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + isLooping = true + setDataSource(context, defaultRingtone) + } } catch (e: SecurityException) { Log.w(TAG, "Failed to create player with ringtone the normal way", e) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt index de3636f502..11f26f9738 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/audio/SignalAudioManager.kt @@ -72,7 +72,7 @@ class SignalAudioManager(private val context: Context, is AudioManagerCommand.Stop -> stop(command.playDisconnect) is AudioManagerCommand.SetDefaultDevice -> setDefaultAudioDevice(command.device, command.clearUserEarpieceSelection) is AudioManagerCommand.SetUserDevice -> selectAudioDevice(command.device) - is AudioManagerCommand.StartIncomingRinger -> startIncomingRinger(command.vibrate) + is AudioManagerCommand.StartIncomingRinger -> startIncomingRinger() is AudioManagerCommand.SilenceIncomingRinger -> silenceIncomingRinger() is AudioManagerCommand.StartOutgoingRinger -> startOutgoingRinger(command.type) } @@ -331,11 +331,11 @@ class SignalAudioManager(private val context: Context, } } - private fun startIncomingRinger(vibrate: Boolean) { - Log.i(TAG, "startIncomingRinger(): vibrate: $vibrate") + private fun startIncomingRinger() { + Log.i(TAG, "startIncomingRinger()") androidAudioManager.mode = AudioManager.MODE_RINGTONE - incomingRinger.start(vibrate) + incomingRinger.start() } private fun silenceIncomingRinger() { diff --git a/app/src/main/res/layout/view_global_search_result.xml b/app/src/main/res/layout/view_global_search_result.xml index bb74bea869..c48887a8ec 100644 --- a/app/src/main/res/layout/view_global_search_result.xml +++ b/app/src/main/res/layout/view_global_search_result.xml @@ -39,15 +39,22 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" android:orientation="vertical"> - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintHorizontal_chainStyle="packed" + app:layout_constraintHorizontal_bias="0" + app:layout_constrainedWidth="true" + app:layout_constraintEnd_toStartOf="@+id/unreadCountIndicator" + /> @@ -77,13 +91,15 @@ android:alpha="0.4" android:layout_weight="1" android:paddingStart="@dimen/small_spacing" - android:layout_gravity="end|center_vertical" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" android:id="@+id/search_result_timestamp" tools:text="@tools:sample/date/hhmmss" android:textAlignment="viewEnd" - android:layout_width="0dp" + android:layout_width="wrap_content" android:layout_height="wrap_content"/> - + - - - - diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/Rfc5724UriTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/Rfc5724UriTest.java deleted file mode 100644 index 9d75662ebc..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/util/Rfc5724UriTest.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (C) 2015 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.thoughtcrime.securesms.util; - -import junit.framework.AssertionFailedError; - -import org.junit.Test; - -import java.net.URISyntaxException; - -import static org.junit.Assert.assertTrue; - -public class Rfc5724UriTest { - - @Test public void testInvalidPath() throws Exception { - final String[] invalidSchemaUris = { - "", - ":", - "sms:", - ":sms", - "sms:?goto=fail", - "sms:?goto=fail&fail=goto" - }; - - for (String uri : invalidSchemaUris) { - try { - new Rfc5724Uri(uri); - throw new AssertionFailedError("URISyntaxException should be thrown"); - } catch (URISyntaxException e) { - // success - } - } - } - - @Test public void testGetSchema() throws Exception { - final String[][] uriTestPairs = { - {"sms:+15555555555", "sms"}, - {"sMs:+15555555555", "sMs"}, - {"smsto:+15555555555?", "smsto"}, - {"mms:+15555555555?a=b", "mms"}, - {"mmsto:+15555555555?a=b&c=d", "mmsto"} - }; - - for (String[] uriTestPair : uriTestPairs) { - final Rfc5724Uri testUri = new Rfc5724Uri(uriTestPair[0]); - assertTrue(testUri.getSchema().equals(uriTestPair[1])); - } - } - - @Test public void testGetPath() throws Exception { - final String[][] uriTestPairs = { - {"sms:+15555555555", "+15555555555"}, - {"sms:%2B555555555", "%2B555555555"}, - {"smsto:+15555555555?", "+15555555555"}, - {"mms:+15555555555?a=b", "+15555555555"}, - {"mmsto:+15555555555?a=b&c=d", "+15555555555"}, - {"sms:+15555555555,+14444444444", "+15555555555,+14444444444"}, - {"sms:+15555555555,+14444444444?", "+15555555555,+14444444444"}, - {"sms:+15555555555,+14444444444?a=b", "+15555555555,+14444444444"}, - {"sms:+15555555555,+14444444444?a=b&c=d", "+15555555555,+14444444444"} - }; - - for (String[] uriTestPair : uriTestPairs) { - final Rfc5724Uri testUri = new Rfc5724Uri(uriTestPair[0]); - assertTrue(testUri.getPath().equals(uriTestPair[1])); - } - } - - @Test public void testGetQueryParams() throws Exception { - final String[][] uriTestPairs = { - {"sms:+15555555555", "a", null}, - {"mms:+15555555555?b=", "a", null}, - {"mmsto:+15555555555?a=", "a", ""}, - {"sms:+15555555555?a=b", "a", "b"}, - {"sms:+15555555555?a=b&c=d", "a", "b"}, - {"sms:+15555555555?a=b&c=d", "b", null}, - {"sms:+15555555555?a=b&c=d", "c", "d"}, - {"sms:+15555555555?a=b&c=d", "d", null} - }; - - for (String[] uriTestPair : uriTestPairs) { - final Rfc5724Uri testUri = new Rfc5724Uri(uriTestPair[0]); - final String paramResult = testUri.getQueryParams().get(uriTestPair[1]); - - if (paramResult == null) assertTrue(uriTestPair[2] == null); - else assertTrue(paramResult.equals(uriTestPair[2])); - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72ec55199e..d96327d4cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -143,6 +143,7 @@ glide-compose = { module = "com.github.bumptech.glide:compose", version = "1.0.0 glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glideVersion" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "daggerHiltVersion" } dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "daggerHiltVersion" } +kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutinesVersion" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutinesVersion" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxJsonVersion" } kotlinx-coroutines-testing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutinesVersion" } diff --git a/libsession/src/main/res/values/attrs.xml b/libsession/src/main/res/values/attrs.xml index 84950d94d6..e4a1f90aa7 100644 --- a/libsession/src/main/res/values/attrs.xml +++ b/libsession/src/main/res/values/attrs.xml @@ -62,11 +62,6 @@ - - - - - diff --git a/scripts/build-and-release.py b/scripts/build-and-release.py index 827f810126..baa2386433 100755 --- a/scripts/build-and-release.py +++ b/scripts/build-and-release.py @@ -224,7 +224,7 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent description='Build and release script for Session Android' ) -parser.add_argument('--build-play-only', action='store_true', help='If set, will only build Play releases and skip F-Droid and Huawei releases.') +parser.add_argument('--build-only', action='store_true', help='If set, will only build APKs and skip all upload/fdroid actions') parser.add_argument('--build-type', help='Build with specified build type. Default: release', default = 'release') args = parser.parse_args() @@ -260,10 +260,6 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent build_type=args.build_type, ) -if args.build_play_only: - print('Skipping F-Droid and Huawei releases as --build-play-only is set.') - sys.exit(0) - print("Building fdroid releases...") fdroid_build_result = build_releases( project_root=project_root, @@ -273,8 +269,9 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent build_type=args.build_type, ) -print("Updating fdroid repo...") -update_fdroid(build=fdroid_build_result, creds=BuildCredentials(credentials['fdroid']), fdroid_workspace=os.path.join(fdroid_repo_path, 'fdroid')) +if not args.build_only: + print("Updating fdroid repo...") + update_fdroid(build=fdroid_build_result, creds=BuildCredentials(credentials['fdroid']), fdroid_workspace=os.path.join(fdroid_repo_path, 'fdroid')) print("Building huawei releases...") huawei_build_result = build_releases( @@ -287,23 +284,24 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent ) # If the a github release draft exists, upload the apks to the release -try: - release_info = json.loads(subprocess.check_output(f'gh release view --json isDraft {play_build_result.version_name}', shell=True, cwd=project_root)) - if release_info['isDraft'] == True: - print(f'Uploading build artifact to the release {play_build_result.version_name} draft...') - files_to_upload = [*play_build_result.apk_paths, - play_build_result.bundle_path, - *huawei_build_result.apk_paths] - upload_commands = ['gh', 'release', 'upload', play_build_result.version_name, '--clobber', *files_to_upload] - subprocess.run(upload_commands, shell=False, cwd=project_root, check=True) - - print('Successfully uploaded these files to the draft release: ') - for file in files_to_upload: - print(file) - else: - print(f'Release {play_build_result.version_name} not a draft. Skipping upload of apks to the release.') -except subprocess.CalledProcessError: - print(f'{play_build_result.version_name} has not had a release draft created. Skipping upload of apks to the release.') +if not args.build_only: + try: + release_info = json.loads(subprocess.check_output(f'gh release view --json isDraft {play_build_result.version_name}', shell=True, cwd=project_root)) + if release_info['isDraft'] == True: + print(f'Uploading build artifact to the release {play_build_result.version_name} draft...') + files_to_upload = [*play_build_result.apk_paths, + play_build_result.bundle_path, + *huawei_build_result.apk_paths] + upload_commands = ['gh', 'release', 'upload', play_build_result.version_name, '--clobber', *files_to_upload] + subprocess.run(upload_commands, shell=False, cwd=project_root, check=True) + + print('Successfully uploaded these files to the draft release: ') + for file in files_to_upload: + print(file) + else: + print(f'Release {play_build_result.version_name} not a draft. Skipping upload of apks to the release.') + except subprocess.CalledProcessError: + print(f'{play_build_result.version_name} has not had a release draft created. Skipping upload of apks to the release.') print('\n=====================')