diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a97bd14cdc..40d675dc2a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -152,7 +152,8 @@ android { """.trimIndent()) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArguments["clearPackageData"] = "true" + testInstrumentationRunner = "org.thoughtcrime.securesms.HiltTestRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "false" testOptions { execution = "ANDROIDX_TEST_ORCHESTRATOR" } @@ -430,6 +431,7 @@ dependencies { testImplementation(libs.mockito.kotlin) androidTestImplementation(libs.mockito.core) androidTestImplementation(libs.mockito.kotlin) + androidTestImplementation(libs.hilt.android.testing) testImplementation(libs.androidx.core) testImplementation(libs.androidx.core.testing) testImplementation(libs.kotlinx.coroutines.testing) @@ -451,9 +453,14 @@ dependencies { testJvmAgent(libs.mockito.core) { isTransitive = false } androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(libs.androidx.espresso.contrib) androidTestImplementation(libs.androidx.espresso.intents) - androidTestImplementation(libs.androidx.espresso.accessibility) + androidTestImplementation(libs.androidx.espresso.contrib) { + exclude(group = "com.google.protobuf", module = "protobuf-lite") + } + androidTestImplementation(libs.androidx.espresso.accessibility) { + exclude(group = "com.google.protobuf", module = "protobuf-lite") + } + androidTestImplementation("com.google.protobuf:protobuf-java:4.33.1") androidTestImplementation(libs.androidx.espresso.web) androidTestImplementation(libs.androidx.idling.concurrent) androidTestImplementation(libs.androidx.espresso.idling.resource) diff --git a/app/src/androidTest/kotlin/org/thoughtcrime/securesms/HiltTestRunner.kt b/app/src/androidTest/kotlin/org/thoughtcrime/securesms/HiltTestRunner.kt new file mode 100644 index 0000000000..c04c5204fd --- /dev/null +++ b/app/src/androidTest/kotlin/org/thoughtcrime/securesms/HiltTestRunner.kt @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { + return super.newApplication(cl, HiltTestApplication::class.java.name, context) + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/thoughtcrime/securesms/message_sending/NetworkSendTest.kt b/app/src/androidTest/kotlin/org/thoughtcrime/securesms/message_sending/NetworkSendTest.kt new file mode 100644 index 0000000000..7ccc164eba --- /dev/null +++ b/app/src/androidTest/kotlin/org/thoughtcrime/securesms/message_sending/NetworkSendTest.kt @@ -0,0 +1,326 @@ +package org.thoughtcrime.securesms.message_sending + +import android.content.Context +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.jobs.DebugTextSendJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.network.SnodeClock +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.crypto.MnemonicCodec +import org.thoughtcrime.securesms.auth.LoggedInState +import org.thoughtcrime.securesms.auth.LoginStateRepository +import org.thoughtcrime.securesms.crypto.MnemonicUtilities +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.model.MessageId +import javax.inject.Inject +import javax.inject.Provider + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class NetworkSendTest { + + companion object { + // Message Counts + private const val NTS_MESSAGE_COUNT = 100 + private const val ONE_ON_ONE_MESSAGE_COUNT = 100 + private const val GROUP_MESSAGE_COUNT = 20 + private const val COMMUNITY_MESSAGE_COUNT = 5 + + // Delays + private const val DEFAULT_DELAY_MS = 250L + + // Timeouts + private const val DEFAULT_EXECUTION_TIMEOUT_MS = 120_000L + private const val LONG_EXECUTION_TIMEOUT_MS = 300_000L + private const val DEFAULT_AWAIT_TIMEOUT_MS = 180_000L + private const val LONG_AWAIT_TIMEOUT_MS = 300_000L + + // Polling + private const val POLL_INTERVAL_MS = 200L + } + + @get:Rule val hiltRule = HiltAndroidRule(this) + + @Inject lateinit var loginStateRepository: LoginStateRepository + + @Inject lateinit var messageSenderProvider: Provider + @Inject lateinit var snodeClockProvider: Provider + @Inject lateinit var storageProvider: Provider + @Inject lateinit var jobQueueProvider: Provider + @Inject lateinit var mmsSmsDatabaseProvider: Provider + + @Inject lateinit var messagingModuleConfiguration: Provider + + @Inject lateinit var recipientRepository: RecipientRepository + @Inject lateinit var debugTextSendJobFactory: DebugTextSendJob.Factory + + // Resolved after we seed login state + private lateinit var messageSender: MessageSender + private lateinit var snodeClock: SnodeClock + private lateinit var storage: Storage + private lateinit var jobQueue: JobQueue + private lateinit var mmsSmsDb: MmsSmsDatabase + + @Before fun setup() { + // SQLCipher relies on native libraries, but the test application does not run the normal app + // initialization. We prepare anything the database needs here before injection happens. + val context = InstrumentationRegistry.getInstrumentation().targetContext + + MessagingModuleConfiguration.configure(context) + + // Skip the automatic VACUUM step during tests to keep startup faster and more stable. + runCatching { + TextSecurePreferences.setLastVacuumNow(context) + } + + // Explicitly load the SQLCipher native library before anything opens the database. + runCatching { System.loadLibrary("sqlcipher") } + .onFailure { throw IllegalStateException("Failed to load SQLCipher native lib (sqlcipher)", it) } + + // Perform injection on the main thread so any Android handlers created during setup + // are attached to the main looper. + InstrumentationRegistry.getInstrumentation().runOnMainSync { + hiltRule.inject() + } + + // Generate a login state the same way the app normally would, but inside the test. + val phrase = "blender balding sabotage javelin cogs fetches duke fatal hitched village sensible oars sensible" + + val codec = MnemonicCodec { fileName -> + MnemonicUtilities.loadFileContents(context, fileName) + } + + val seed = codec.sanitizeAndDecodeAsByteArray(phrase) + + // LoggedInState expects a 16‑byte seed. + check(seed.size == 16) { "Unexpected seed length=${seed.size}, expected 16" } + + loginStateRepository.update { LoggedInState.generate(seed) } + + // After login state is set, we can safely create database and networking singletons. + // Some of them create Android Handlers, so this must run on the main thread. + InstrumentationRegistry.getInstrumentation().runOnMainSync { + snodeClock = snodeClockProvider.get() + jobQueue = jobQueueProvider.get() + messageSender = messageSenderProvider.get() + + // Attempt to reuse the existing encrypted database. If it cannot be opened + // (for example due to a mismatched key), delete it once and retry. + storage = try { + storageProvider.get() + } catch (t: Throwable) { + if (looksLikeSqlCipherWrongKeyOrCorruptDb(t)) { + Log.w("NetworkSendTest", "Opening session.db failed; deleting and retrying once", t) + deleteSessionDb(context) + storageProvider.get() + } else { + throw t + } + } + mmsSmsDb = mmsSmsDatabaseProvider.get() + } + } + + private fun deleteSessionDb(context: Context) { + runCatching { context.deleteDatabase("session.db") } + runCatching { context.getDatabasePath("session.db-wal").delete() } + runCatching { context.getDatabasePath("session.db-shm").delete() } + } + + private fun looksLikeSqlCipherWrongKeyOrCorruptDb(t: Throwable): Boolean { + // SQLCipher often reports key mismatches as "file is not a database". + // Only retry for known database corruption or wrong‑key cases. + var cur: Throwable? = t + while (cur != null) { + val msg = cur.message?.lowercase().orEmpty() + if ( + msg.contains("file is not a database") || + msg.contains("not a database") || + msg.contains("file is encrypted") || + msg.contains("malformed") + ) return true + + // Some devices wrap the real exception; also catch common SQLite exception types by name + val name = cur.javaClass.name + if ( + name.contains("SQLiteException") && + (msg.contains("not a database") || msg.contains("file is not a database") || msg.contains("malformed")) + ) return true + + cur = cur.cause + } + return false + } + + private data class BatchSummary( + val attempted: Int, + val sent: List, + val failed: List, + val timedOut: List, + ) + + private suspend fun awaitTerminalStates( + ids: List, + timeoutMs: Long = 180_000L, + pollMs: Long = 200L, + ): BatchSummary = withTimeout(timeoutMs) { + val remaining = ids.toMutableSet() + val sent = mutableListOf() + val failed = mutableListOf() + + while (remaining.isNotEmpty()) { + val it = remaining.iterator() + while (it.hasNext()) { + val id = it.next() + when (mmsSmsDb.getOutgoingTerminalState(id)) { + MmsSmsDatabase.OutgoingTerminalState.SENT -> { sent += id; it.remove() } + MmsSmsDatabase.OutgoingTerminalState.FAILED -> { failed += id; it.remove() } + MmsSmsDatabase.OutgoingTerminalState.PENDING -> Unit + } + } + if (remaining.isNotEmpty()) delay(pollMs) + } + + BatchSummary( + attempted = ids.size, + sent = sent, + failed = failed, + timedOut = emptyList() + ) + } + + @Test fun send_real_network_repeatable_user_nts() = runBlocking { + val recipient = Address.fromSerialized("05301f684ff55f168fcc270053788609c9a711751c5e636c4e587d804ae435a569") + + // ensure thread exists + val threadId = storage.getOrCreateThreadIdFor(recipient) + + val job = debugTextSendJobFactory.create( + threadId = threadId, + address = recipient, + count = NTS_MESSAGE_COUNT, + delayBetweenMessagesMs = DEFAULT_DELAY_MS, + prefix = "hello from DebugTextSendJob NTS" + ) + + val messageIds = withTimeout(DEFAULT_EXECUTION_TIMEOUT_MS) { + job.executeAndReturnMessageIds(dispatcherName = "instrumentation-test") + } + + val summary = awaitTerminalStates( + ids = messageIds, + timeoutMs = DEFAULT_AWAIT_TIMEOUT_MS, + pollMs = POLL_INTERVAL_MS + ) + println("NetworkSendTest: NTS attempted=${summary.attempted} sent=${summary.sent.size} failed=${summary.failed.size}") + if (summary.failed.isNotEmpty()) { + throw AssertionError("NTS failed message ids: ${summary.failed.map { it.id }}") + } + } + + @Test fun send_real_network_repeatable_one_on_one() = runBlocking { + val recipient = Address.fromSerialized("0507012662d6972db5ba1f1f6e5501e3b6c6651c10c593d44153546c69fbe77322") + + // ensure thread exists + val threadId = storage.getOrCreateThreadIdFor(recipient) + + val job = debugTextSendJobFactory.create( + threadId = threadId, + address = recipient, + count = ONE_ON_ONE_MESSAGE_COUNT, + delayBetweenMessagesMs = DEFAULT_DELAY_MS, + prefix = "hello from DebugTextSendJob 1:1" + ) + + val messageIds = withTimeout(LONG_EXECUTION_TIMEOUT_MS) { + job.executeAndReturnMessageIds(dispatcherName = "instrumentation-test-1:1") + } + + val summary = awaitTerminalStates( + ids = messageIds, + timeoutMs = LONG_AWAIT_TIMEOUT_MS, + pollMs = POLL_INTERVAL_MS + ) + + Log.i("NetworkSendTest", "1:1 attempted=${summary.attempted} sent=${summary.sent.size} failed=${summary.failed.size}") + if (summary.failed.isNotEmpty()) { + throw AssertionError("1:1 failed message ids: ${summary.failed.map { it.id }}") + } + } + + @Test fun send_real_network_repeatable_group() = runBlocking { + val recipient = Address.fromSerialized("034ccd4890302d625eac887b660403140d9a8e131cda797d77d44bec8d5111bc24") + + // ensure thread exists + val threadId = storage.getOrCreateThreadIdFor(recipient) + + val job = debugTextSendJobFactory.create( + threadId = threadId, + address = recipient, + count = GROUP_MESSAGE_COUNT, + delayBetweenMessagesMs = DEFAULT_DELAY_MS, + prefix = "hello from DebugTextSendJob Group" + ) + + val messageIds = withTimeout(DEFAULT_EXECUTION_TIMEOUT_MS) { + job.executeAndReturnMessageIds(dispatcherName = "instrumentation-test-group") + } + + val summary = awaitTerminalStates( + ids = messageIds, + timeoutMs = DEFAULT_AWAIT_TIMEOUT_MS, + pollMs = POLL_INTERVAL_MS + ) + + Log.i("NetworkSendTest", "Group attempted=${summary.attempted} sent=${summary.sent.size} failed=${summary.failed.size}") + if (summary.failed.isNotEmpty()) { + throw AssertionError("Group failed message ids: ${summary.failed.map { it.id }}") + } + } + + @Test fun send_real_network_repeatable_community() = runBlocking { + val recipient = Address.fromSerialized("community://https%3A%2F%2Ftest-chat.session.codes?room=testing-all-the-things") + + // ensure thread exists + val threadId = storage.getOrCreateThreadIdFor(recipient) + + val job = debugTextSendJobFactory.create( + threadId = threadId, + address = recipient, + count = COMMUNITY_MESSAGE_COUNT, + delayBetweenMessagesMs = DEFAULT_DELAY_MS, + prefix = "test community" + ) + + val messageIds = withTimeout(DEFAULT_EXECUTION_TIMEOUT_MS) { + job.executeAndReturnMessageIds(dispatcherName = "instrumentation-test-open-group") + } + + val summary = awaitTerminalStates( + ids = messageIds, + timeoutMs = DEFAULT_AWAIT_TIMEOUT_MS, + pollMs = POLL_INTERVAL_MS + ) + + Log.i("NetworkSendTest", "Community attempted=${summary.attempted} sent=${summary.sent.size} failed=${summary.failed.size}") + if (summary.failed.isNotEmpty()) { + throw AssertionError("Community failed message ids: ${summary.failed.map { it.id }}") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index 46fa5e6f12..f32440795f 100644 --- a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -1,6 +1,7 @@ package org.session.libsession.messaging import android.content.Context +import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.serialization.json.Json import org.session.libsession.database.MessageDataProvider @@ -18,6 +19,7 @@ import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.util.AvatarUtils import javax.inject.Inject import javax.inject.Singleton +import kotlin.jvm.java @Singleton class MessagingModuleConfiguration @Inject constructor( @@ -39,11 +41,6 @@ class MessagingModuleConfiguration @Inject constructor( ) { companion object { - @JvmStatic - @Deprecated("Use properly DI components instead") - val shared: MessagingModuleConfiguration - get() = context.getSystemService(MESSAGING_MODULE_SERVICE) as MessagingModuleConfiguration - const val MESSAGING_MODULE_SERVICE: String = "MessagingModuleConfiguration_MESSAGING_MODULE_SERVICE" private lateinit var context: Context @@ -52,5 +49,23 @@ class MessagingModuleConfiguration @Inject constructor( fun configure(context: Context) { this.context = context } + + /** + * Works in BOTH: + * - Production (ApplicationContext) + * - Hilt tests (HiltTestApplication) + */ + @JvmStatic + val shared: MessagingModuleConfiguration + get() { + val appContext = context.applicationContext + + val entryPoint = EntryPointAccessors.fromApplication( + appContext, + MessagingModuleConfigurationEntryPoint::class.java + ) + + return entryPoint.messagingModuleConfiguration() + } } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfigurationEntryPoint.kt b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfigurationEntryPoint.kt new file mode 100644 index 0000000000..b91719c3e2 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/MessagingModuleConfigurationEntryPoint.kt @@ -0,0 +1,11 @@ +package org.session.libsession.messaging + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface MessagingModuleConfigurationEntryPoint { + fun messagingModuleConfiguration(): MessagingModuleConfiguration +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/DebugAttachmentSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/DebugAttachmentSendJob.kt new file mode 100644 index 0000000000..a15ce2de1f --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/jobs/DebugAttachmentSendJob.kt @@ -0,0 +1,254 @@ +package org.session.libsession.messaging.jobs + +import android.app.Application +import android.net.Uri +import android.util.Log +import com.esotericsoftware.kryo.Kryo +import com.esotericsoftware.kryo.io.Input +import com.esotericsoftware.kryo.io.Output +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.delay +import network.loki.messenger.BuildConfig +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.messaging.messages.applyExpiryMode +import org.session.libsession.messaging.messages.signal.OutgoingMediaMessage +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.sending_receiving.attachments.Attachment +import org.session.libsession.messaging.utilities.Data +import org.session.libsession.network.SnodeClock +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.database.MmsDatabase +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.mms.GifSlide +import org.thoughtcrime.securesms.mms.ImageSlide +import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.mms.VideoSlide +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.util.MediaUtil + +class DebugAttachmentSendJob @AssistedInject constructor( + @Assisted("threadId") private val threadId: Long, + @Assisted("address") private val addressSerialized: String, + @Assisted("count") private val count: Int, + @Assisted("delayMs") private val delayBetweenSendsMs: Long, + @Assisted("prefix") private val prefix: String, + @Assisted("body") private val body: String?, + @Assisted("mediaSpecs") private val mediaSpecsBytes: ByteArray, + + private val application: Application, + private val recipientRepository: RecipientRepository, + private val proStatusManager: ProStatusManager, + private val messageSender: MessageSender, + private val snodeClock: SnodeClock, + private val mmsDb: MmsDatabase, +) : Job { + + data class MediaSpec( + val uriString: String = "", + val mimeType: String = "", + val filename: String? = null, + val width: Int = 0, + val height: Int = 0, + val caption: String? = null + ) + + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + override val maxFailureCount: Int = 3 + + companion object { + const val KEY = "DebugAttachmentSendJob" + + private const val THREAD_ID_KEY = "thread_id" + private const val ADDRESS_KEY = "address" + private const val COUNT_KEY = "count" + private const val DELAY_KEY = "delay_ms" + private const val PREFIX_KEY = "prefix" + private const val BODY_KEY = "body" + private const val MEDIA_SPECS_KEY = "media_specs" + + private const val MAX_BUFFER_SIZE_BYTES = 1_000_000 // ~1MB + + private fun encodeSpecs(specs: List): ByteArray { + val kryo = Kryo().apply { isRegistrationRequired = false } + val output = Output(ByteArray(4096), MAX_BUFFER_SIZE_BYTES) + kryo.writeClassAndObject(output, specs) + output.close() + return output.toBytes() + } + + @Suppress("UNCHECKED_CAST") + private fun decodeSpecs(bytes: ByteArray): List { + val kryo = Kryo().apply { isRegistrationRequired = false } + val input = Input(bytes) + val obj = kryo.readClassAndObject(input) + input.close() + return (obj as? List).orEmpty() + } + } + + override suspend fun execute(dispatcherName: String) { + if (!BuildConfig.DEBUG) { + delegate?.handleJobFailedPermanently(this, dispatcherName, IllegalStateException("Debug-only job")) + return + } + + val address = Address.fromSerialized(addressSerialized) as Address.Conversable + val recipient = recipientRepository.getRecipientSync(address) + + val specs = decodeSpecs(mediaSpecsBytes) + if (specs.isEmpty() || count <= 0) { + delegate?.handleJobSucceeded(this, dispatcherName) + return + } + + repeat(count) { i -> + val attachments = buildAttachments(specs) + + val sentTimestamp = snodeClock.currentTimeMillis() + i + val message = VisibleMessage().applyExpiryMode(address).apply { + this.sentTimestamp = sentTimestamp + this.text = when { + body.isNullOrBlank() -> "$prefix (#${i + 1})" + else -> "$body (#${i + 1})" + } + } + + proStatusManager.addProFeatures(message) + + val expiresInMs = recipient.expiryMode.expiryMillis ?: 0L + val expireStartedAtMs = if (recipient.expiryMode is ExpiryMode.AfterSend) sentTimestamp else 0L + + val outgoing = OutgoingMediaMessage( + message = message, + recipient = recipient.address, + attachments = attachments, + outgoingQuote = null, + linkPreview = null, + expiresInMillis = expiresInMs, + expireStartedAt = expireStartedAtMs + ) + + message.id = MessageId( + mmsDb.insertMessageOutbox( + outgoing, + threadId, + false, + runThreadUpdate = true + ), + mms = true + ) + + Log.d(KEY, "DebugAttachmentSendJob send #${i + 1}/${count} attachments=${attachments.size}") + messageSender.send(message, recipient.address, null, null) + + if (delayBetweenSendsMs > 0) delay(delayBetweenSendsMs) + } + + delegate?.handleJobSucceeded(this, dispatcherName) + } + + private fun buildAttachments(specs: List): List { + val slideDeck = SlideDeck() + val ctx = application + + for (s in specs) { + val uri = Uri.parse(s.uriString) + when { + MediaUtil.isVideoType(s.mimeType) -> + slideDeck.addSlide(VideoSlide(ctx, uri, s.filename, 0, s.caption)) + MediaUtil.isGif(s.mimeType) -> + slideDeck.addSlide( + GifSlide( + ctx, + uri, + s.filename, + 0, + s.width, + s.height, + s.caption + ) + ) + MediaUtil.isImageType(s.mimeType) -> + slideDeck.addSlide( + ImageSlide( + ctx, + uri, + s.filename, + 0, + s.width, + s.height, + s.caption + ) + ) + else -> { + // ignore unsupported types for now + } + } + } + + return slideDeck.asAttachments() + } + + override fun serialize(): Data { + return Data.Builder() + .putLong(THREAD_ID_KEY, threadId) + .putString(ADDRESS_KEY, addressSerialized) + .putInt(COUNT_KEY, count) + .putLong(DELAY_KEY, delayBetweenSendsMs) + .putString(PREFIX_KEY, prefix) + .putString(BODY_KEY, body!!) + .putByteArray(MEDIA_SPECS_KEY, mediaSpecsBytes) + .build() + } + + override fun getFactoryKey(): String = KEY + + @AssistedFactory + interface Factory : Job.DeserializeFactory { + fun create( + @Assisted("threadId") threadId: Long, + @Assisted("address") addressSerialized: String, + @Assisted("count") count: Int, + @Assisted("delayMs") delayBetweenSendsMs: Long, + @Assisted("prefix") prefix: String, + @Assisted("body") body: String?, + @Assisted("mediaSpecs") mediaSpecsBytes: ByteArray, + ): DebugAttachmentSendJob + + fun create( + threadId: Long, + address: Address.Conversable, + count: Int, + delayBetweenSendsMs: Long, + prefix: String, + body: String?, + mediaSpecs: List, + ): DebugAttachmentSendJob = create( + threadId = threadId, + addressSerialized = address.toString(), + count = count, + delayBetweenSendsMs = delayBetweenSendsMs, + prefix = prefix, + body = body, + mediaSpecsBytes = encodeSpecs(mediaSpecs) + ) + + override fun create(data: Data): DebugAttachmentSendJob? { + return create( + threadId = data.getLong(THREAD_ID_KEY), + addressSerialized = data.getString(ADDRESS_KEY)!!, + count = data.getInt(COUNT_KEY), + delayBetweenSendsMs = data.getLong(DELAY_KEY), + prefix = data.getString(PREFIX_KEY)!!, + body = data.getString(BODY_KEY), + mediaSpecsBytes = data.getByteArray(MEDIA_SPECS_KEY)!! + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/DebugTextSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/DebugTextSendJob.kt new file mode 100644 index 0000000000..fc74d8e5ae --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/jobs/DebugTextSendJob.kt @@ -0,0 +1,149 @@ +package org.session.libsession.messaging.jobs + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.delay +import network.loki.messenger.BuildConfig +import org.session.libsession.messaging.messages.applyExpiryMode +import org.session.libsession.messaging.messages.signal.OutgoingTextMessage +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.messaging.utilities.Data +import org.session.libsession.network.SnodeClock +import org.session.libsession.utilities.Address +import org.thoughtcrime.securesms.database.SmsDatabase +import org.thoughtcrime.securesms.database.model.MessageId + +class DebugTextSendJob @AssistedInject constructor( + @Assisted("threadId") private val threadId: Long, + @Assisted("address") private val addressSerialized: String, + @Assisted("count") private val count: Int, + @Assisted("delayMs") private val delayBetweenMessagesMs: Long, + @Assisted("prefix") private val prefix: String, + + private val smsDb: SmsDatabase, + private val messageSender: MessageSender, + private val snodeClock: SnodeClock, +) : Job { + + override var delegate: JobDelegate? = null + override var id: String? = null + override var failureCount: Int = 0 + + override val maxFailureCount: Int = 3 + + companion object { + const val KEY = "DebugTextSendJob" + + private const val THREAD_ID_KEY = "thread_id" + private const val ADDRESS_KEY = "address" + private const val COUNT_KEY = "count" + private const val DELAY_KEY = "delay_ms" + private const val PREFIX_KEY = "prefix" + } + + override suspend fun execute(dispatcherName: String) { + // Keep Job API intact, but allow tests to obtain ids via the helper. + executeAndReturnMessageIds(dispatcherName) + } + + /** + * Test/automation helper: runs the same send loop but returns the inserted DB ids so tests + * can track status (sent/delivered) via DB state transitions. + */ + suspend fun executeAndReturnMessageIds(dispatcherName: String): List { + if (!BuildConfig.DEBUG) { + // Safety guard + delegate?.handleJobFailedPermanently(this, dispatcherName, IllegalStateException("Debug-only job")) + return emptyList() + } + + val address = Address.fromSerialized(addressSerialized) + val messageIds = ArrayList(count) + + repeat(count) { i -> + // Ensure unique timestamps across tight loops. + val ts = snodeClock.currentTimeMillis() + + val message = VisibleMessage().applyExpiryMode(address).apply { + sentTimestamp = ts + text = "$prefix #${i + 1}" + } + + val outgoing = OutgoingTextMessage( + message = message, + recipient = address, + expiresInMillis = 0, + expireStartedAtMillis = 0 + ) + + val messageId = MessageId( + id = smsDb.insertMessageOutbox( + threadId, + outgoing, + false, + message.sentTimestamp!!, + true + ), + false + ) + + message.id = messageId + messageIds += messageId + + messageSender.send(message, address) + + if (delayBetweenMessagesMs > 0) delay(delayBetweenMessagesMs) + } + + return messageIds + } + + override fun serialize(): Data { + return Data.Builder() + .putLong(THREAD_ID_KEY, threadId) + .putString(ADDRESS_KEY, addressSerialized) + .putInt(COUNT_KEY, count) + .putLong(DELAY_KEY, delayBetweenMessagesMs) + .putString(PREFIX_KEY, prefix) + .build() + } + + override fun getFactoryKey(): String = KEY + + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + @Assisted("threadId") threadId: Long, + @Assisted("address") addressSerialized: String, + @Assisted("count") count: Int, + @Assisted("delayMs") delayBetweenMessagesMs: Long, + @Assisted("prefix") prefix: String, + ): DebugTextSendJob + + fun create( + threadId: Long, + address: Address, + count: Int, + delayBetweenMessagesMs: Long, + prefix: String, + ): DebugTextSendJob = create( + threadId = threadId, + addressSerialized = address.toString(), + count = count, + delayBetweenMessagesMs = delayBetweenMessagesMs, + prefix = prefix + ) + + override fun create(data: Data): DebugTextSendJob? { + return create( + threadId = data.getLong(THREAD_ID_KEY), + addressSerialized = data.getString(ADDRESS_KEY)!!, + count = data.getInt(COUNT_KEY), + delayBetweenMessagesMs = data.getLong(DELAY_KEY), + prefix = data.getString(PREFIX_KEY)!!, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt index 3595c928d0..9232675f36 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/JobQueue.kt @@ -125,15 +125,20 @@ class JobQueue @Inject constructor( when (val job = queue.receive()) { is InviteContactsJob, is AttachmentUploadJob, - is MessageSendJob -> { + is MessageSendJob, + is DebugTextSendJob, + is DebugAttachmentSendJob -> { txQueue.send(job) } + is AttachmentDownloadJob -> { mediaQueue.send(job) } + is OpenGroupDeleteJob -> { openGroupQueue.send(job) } + is TrimThreadJob -> { if (job.communityAddress != null) { openGroupQueue.send(job) diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index 663ee8079e..b4972d29d4 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -9,6 +9,8 @@ class SessionJobManagerFactories @Inject constructor( private val messageSendJobFactory: MessageSendJob.Factory, private val deleteJobFactory: OpenGroupDeleteJob.Factory, private val inviteContactsJobFactory: InviteContactsJob.Factory, + private val debugTextSendJobFactory: DebugTextSendJob.Factory, + private val debugAttachmentSendJobFactory : DebugAttachmentSendJob.Factory ) { fun getSessionJobFactories(): Map> { @@ -19,6 +21,8 @@ class SessionJobManagerFactories @Inject constructor( TrimThreadJob.KEY to trimThreadFactory, OpenGroupDeleteJob.KEY to deleteJobFactory, InviteContactsJob.KEY to inviteContactsJobFactory, + DebugTextSendJob.KEY to debugTextSendJobFactory, + DebugAttachmentSendJob.KEY to debugAttachmentSendJobFactory ) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 0614aff292..e68d74b1bc 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -80,6 +80,20 @@ class MessageSender @Inject constructor( private val jobQueue: Provider, ) { + companion object { + private var debugLabel = "MessageSender" + private const val CH_SNODE = "SNODE" + private const val CH_OPEN_GROUP = "OPEN GROUP" + private const val CH_OPEN_GROUP_INBOX = "OPEN GROUP INBOX" + + private const val PHASE_ENQUEUE = "Enqueue" + private const val PHASE_NETWORK_START = "Network start" + private const val PHASE_NETWORK_SUCCESS = "Network success" + private const val PHASE_NETWORK_FAILED = "Network failed" + private const val PHASE_MARKED_SENT = "Marked SENT" + private const val PHASE_MARKED_FAILED = "Marked FAILED" + } + // Error sealed class Error(val description: String, cause: Throwable? = null) : Exception(description, cause) { class InvalidMessage : Error("Invalid message.") @@ -97,6 +111,13 @@ class MessageSender @Inject constructor( } } + private fun log(message: String, e: Throwable? = null) { + Log.d(javaClass.simpleName, "$debugLabel: $message", e) + } + + private fun logE(message: String, e: Throwable? = null) { + Log.e(javaClass.simpleName, "$debugLabel: $message", e) + } private fun SessionProtos.DataMessage.Builder.copyProfileFromConfig() { configFactory.withUserConfigs { @@ -247,6 +268,7 @@ class MessageSender @Inject constructor( // One-on-One Chats & Closed Groups private suspend fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false) { + log("$PHASE_NETWORK_START ($CH_SNODE):\nmsgId=${message.id}\ndest=$destination") // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { handleFailedMessageSend(message, error, isSyncMessage) @@ -292,12 +314,14 @@ class MessageSender @Inject constructor( if (sendResult.isSuccess) { message.serverHash = sendResult.getOrThrow().hash + log("$PHASE_NETWORK_SUCCESS ($CH_SNODE):\nmsgId=${message.id}\nhash=${message.serverHash}") handleSuccessfulMessageSend(message, destination, isSyncMessage) } else { throw sendResult.exceptionOrNull()!! } } catch (exception: Exception) { if (exception !is CancellationException) { + logE("$PHASE_NETWORK_FAILED ($CH_SNODE):\nmsgId=${message.id}", exception) handleFailure(exception) } @@ -333,6 +357,7 @@ class MessageSender @Inject constructor( // Open Groups private suspend fun sendToOpenGroupDestination(destination: Destination, message: Message) { + log("$PHASE_NETWORK_START ($CH_OPEN_GROUP):\nmsgId=${message.id}\ndestination=$destination") if (message.sentTimestamp == null) { message.sentTimestamp = snodeClock.currentTimeMillis() } @@ -408,6 +433,7 @@ class MessageSender @Inject constructor( ) message.openGroupServerMessageID = response.id + log("$PHASE_NETWORK_SUCCESS ($CH_OPEN_GROUP):\nmsgId=${message.id}\nserverId=${message.openGroupServerMessageID}") handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = response.postedMills) return } @@ -435,6 +461,7 @@ class MessageSender @Inject constructor( )) message.openGroupServerMessageID = response.id + log("$PHASE_NETWORK_SUCCESS ($CH_OPEN_GROUP_INBOX):\nmsgId=${message.id}\nserverId=${message.openGroupServerMessageID}") handleSuccessfulMessageSend(message, destination, openGroupSentTimestamp = response.postedAt?.toEpochMilli() ?: 0L) return @@ -449,6 +476,7 @@ class MessageSender @Inject constructor( // Result Handling private fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { + log("$PHASE_MARKED_SENT:\nmsgId=${message.id}\nfinalTimestamp=${message.sentTimestamp}") val userPublicKey = storage.getUserPublicKey()!! // Ignore future self-sends storage.addReceivedMessageTimestamp(message.sentTimestamp!!) @@ -506,7 +534,7 @@ class MessageSender @Inject constructor( try { sendToSnodeDestination(Destination.Contact(userPublicKey), message, true) } catch (ec: Exception) { - Log.e("MessageSender", "Unable to send sync message", ec) + logE("Unable to send sync message", ec) } } } @@ -514,6 +542,7 @@ class MessageSender @Inject constructor( fun handleFailedMessageSend(message: Message, error: Exception, isSyncMessage: Boolean = false) { val messageId = message.id ?: return + logE("$PHASE_MARKED_FAILED\nmsgId=$messageId\nsync=$isSyncMessage", error) // no need to handle if message is marked as deleted if (messageDataProvider.isDeletedMessage(messageId)){ @@ -550,6 +579,7 @@ class MessageSender @Inject constructor( message.threadID = threadID val destination = Destination.from(address, configFactory) val job = messageSendJobFactory.create(message, destination, statusCallback) + log("$PHASE_ENQUEUE:\nmsgId=${message.id}\nthread=${message.threadID}\ndest=$address") jobQueue.get().add(job) // if we are sending a 'Note to Self' make sure it is not hidden 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 81a63f787d..f4a32268d8 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -36,6 +36,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.CLASSIC_ import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_HAS_COPIED_DONATION_URL import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_HAS_DONATED import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SEEN_DONATION_CTA_AMOUNT +import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SEND_MESSAGE_RAMP import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW import org.session.libsession.utilities.TextSecurePreferences.Companion.ENVIRONMENT import org.session.libsession.utilities.TextSecurePreferences.Companion.FOLLOW_SYSTEM_SETTINGS @@ -259,6 +260,9 @@ interface TextSecurePreferences { fun isSendWithEnterEnabled() : Boolean fun updateBooleanFromKey(key : String, value : Boolean) + fun setDebugSendMessageRampEnabled(enable: Boolean) + fun isDebugSendMessageRampEnabled(): Boolean + var deprecationStateOverride: String? var deprecatedTimeOverride: ZonedDateTime? var deprecatingStartTimeOverride: ZonedDateTime? @@ -403,6 +407,8 @@ interface TextSecurePreferences { const val SUBSCRIPTION_PROVIDER = "session_subscription_provider" const val DEBUG_AVATAR_REUPLOAD = "debug_avatar_reupload" + const val DEBUG_SEND_MESSAGE_RAMP = "debug_send_message_ramp" + const val HAS_CHECKED_DOZE_WHITELIST = "has_checked_doze_whitelist" // Donation @@ -652,6 +658,11 @@ interface TextSecurePreferences { fun setHaveWarnedUserAboutSavingAttachments(context: Context) { setBooleanPreference(context, HAVE_WARNED_USER_ABOUT_SAVING_ATTACHMENTS, true) } + + @JvmStatic + fun isDebugSendMessageRampEnabled(context : Context): Boolean { + return getBooleanPreference(context, DEBUG_SEND_MESSAGE_RAMP, false) + } } } @@ -1543,6 +1554,14 @@ class AppTextSecurePreferences @Inject constructor( return getBooleanPreference(SEND_WITH_ENTER, false) } + override fun setDebugSendMessageRampEnabled(enable: Boolean) { + setBooleanPreference(DEBUG_SEND_MESSAGE_RAMP, enable) + } + + override fun isDebugSendMessageRampEnabled(): Boolean { + return getBooleanPreference(DEBUG_SEND_MESSAGE_RAMP, false) + } + override fun updateBooleanFromKey(key: String, value: Boolean) { setBooleanPreference(key, value) _events.tryEmit(key) 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 8fd65ef538..49c2537bdd 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 @@ -90,6 +90,7 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.databinding.ActivityConversationV2Binding import network.loki.messenger.libsession_util.util.ExpiryMode @@ -2382,6 +2383,10 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } + override fun sendDebugMessage() { + viewModel.debugRampSending() + } + override fun commitInputContent(contentUri: Uri) { val recipient = viewModel.recipient val mimeType = MediaUtil.getMimeType(this, contentUri)!! @@ -2681,6 +2686,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, intent ?: return val body = intent.getStringExtra(MediaSendActivity.EXTRA_MESSAGE) val mediaList = intent.getParcelableArrayListExtra(MediaSendActivity.EXTRA_MEDIA) ?: return + val debugUpload = intent.getBooleanExtra(MediaSendActivity.EXTRA_DEBUG_UPLOAD, false) + val slideDeck = SlideDeck() for (media in mediaList) { val mediaFilename: String? = media.filename @@ -2693,7 +2700,25 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } } - sendAttachments(slideDeck.asAttachments(), body) + + val attachments = slideDeck.asAttachments() + + // If DEBUG, attempt to send as many times as "count" + if (BuildConfig.DEBUG && debugUpload) { + val count = 20 + val delayBetweenSendsMs = 300L + + // ViewModel will enqueue a JobQueue job that re-builds attachments per send. + viewModel.debugRampAttachmentSending( + media = mediaList, + body = body, + count = count, + delayMs = delayBetweenSendsMs, + prefix = "Upload" + ) + } else { + sendAttachments(attachments, body) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 4d94bb1dd8..50ccdca826 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import network.loki.messenger.BuildConfig import network.loki.messenger.R import network.loki.messenger.libsession_util.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.PRIORITY_VISIBLE @@ -55,6 +56,8 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.DebugAttachmentSendJob +import org.session.libsession.messaging.jobs.DebugTextSendJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.open_groups.GroupMemberRole import org.session.libsession.messaging.open_groups.OpenGroupApi @@ -111,6 +114,7 @@ import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.groups.ExpiredGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.ui.SimpleDialogData @@ -168,6 +172,8 @@ class ConversationViewModel @AssistedInject constructor( private val audioPlaybackManager: AudioPlaybackManager, private val loginStateRepository: LoginStateRepository, private val jobQueue: Provider, + private val debugTextSendJobFactory: DebugTextSendJob.Factory, + private val debugAttachmentSendJobFactory: DebugAttachmentSendJob.Factory ) : InputbarViewModel( application = application, proStatusManager = proStatusManager, @@ -1556,6 +1562,68 @@ class ConversationViewModel @AssistedInject constructor( audioPlaybackManager.cyclePlaybackSpeed() } + fun debugRampSending( + count: Int = 100, + delayMs: Long = 50, + prefix: String = "Auto", + ) { + if (!BuildConfig.DEBUG) return + + viewModelScope.launch(Dispatchers.IO) { + val threadId = threadIdFlow.filterNotNull().first() + implicitlyApproveRecipient()?.join() + + jobQueue.get().add( + debugTextSendJobFactory.create( + threadId = threadId, + address = address, + count = count, + delayBetweenMessagesMs = delayMs, + prefix = prefix + ) + ) + } + } + + fun debugRampAttachmentSending( + media: List, + body: String?, + count: Int = 20, + delayMs: Long = 300L, + prefix: String = "Upload", + ) { + if (!BuildConfig.DEBUG) return + + viewModelScope.launch(Dispatchers.IO) { + val threadId = threadIdFlow.filterNotNull().first() + implicitlyApproveRecipient()?.join() + + val specs = media.map { m -> + DebugAttachmentSendJob.MediaSpec( + uriString = m.uri.toString(), + mimeType = m.mimeType, + filename = m.filename, + width = m.width, + height = m.height, + caption = m.caption + ) + } + + jobQueue.get().add( + debugAttachmentSendJobFactory.create( + threadId = threadId, + address = address, + count = count, + delayBetweenSendsMs = delayMs, + prefix = prefix, + body = body, + mediaSpecs = specs + ) + ) + } + } + + @AssistedFactory interface Factory { fun create(address: Address.Conversable): ConversationViewModel diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt index 52e14b8220..0df81d8640 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBar.kt @@ -16,6 +16,7 @@ import android.widget.TextView import androidx.core.view.isGone import androidx.core.view.isVisible import com.bumptech.glide.RequestManager +import com.jakewharton.rxbinding3.view.visibility import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.ViewInputBarBinding @@ -110,6 +111,10 @@ class InputBar @JvmOverloads constructor( contentDescription = context.getString(R.string.AccessibilityId_send) } + private val debugSendButton = InputBarButton(context, R.drawable.ic_settings, isSendButton = true).apply { + contentDescription = context.getString(R.string.AccessibilityId_send) + } + private val textColor: Int by lazy { context.getColorFromAttr(android.R.attr.textColorPrimary) } @@ -140,6 +145,20 @@ class InputBar @JvmOverloads constructor( } } + //debug + if(TextSecurePreferences.isDebugSendMessageRampEnabled(context)){ + binding.debugButtonContainer.visibility = VISIBLE + binding.debugButtonContainer.addView(debugSendButton) + debugSendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + debugSendButton.onUp = { e -> + if (debugSendButton.contains(PointF(e.x, e.y))) { + delegate?.sendDebugMessage() + } + } + }else{ + binding.debugButtonContainer.visibility = GONE + } + // Edit text binding.inputBarEditText.setOnEditorActionListener(this) // Prevent some IMEs from switching to fullscreen/extracted text mode in landscape (shows IME-owned text field). @@ -406,6 +425,7 @@ interface InputBarDelegate { fun onMicrophoneButtonCancel(event: MotionEvent) fun onMicrophoneButtonUp(event: MotionEvent) fun sendMessage() + fun sendDebugMessage() fun commitInputContent(contentUri: Uri) fun onCharLimitTapped() } 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 2293988d0c..741ef07861 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -572,6 +572,52 @@ public static void migrateLegacyCommunityAddresses2(final SQLiteDatabase db) { migrateLegacyCommunityAddresses2(db, MmsDatabase.TABLE_NAME); } + public enum OutgoingTerminalState { + SENT, + FAILED, + PENDING + } + + /** + * Test/diagnostic helper: returns the terminal send state for an outgoing SMS row. + * + * SENT -> base type is BASE_SENT_TYPE + * FAILED -> base type is BASE_SENT_FAILED_TYPE or BASE_SYNC_FAILED_TYPE + * PENDING -> anything else (e.g. BASE_SENDING_TYPE / BASE_SYNCING_TYPE / BASE_RESYNCING_TYPE) + */ + public @NonNull OutgoingTerminalState getOutgoingTerminalState(@NonNull MessageId messageId) { + final String table; + final String typeColumn; + + if (messageId.isMms()) { + table = MmsDatabase.TABLE_NAME; + typeColumn = MmsDatabase.MESSAGE_BOX; + } else { + table = SmsDatabase.TABLE_NAME; + typeColumn = SmsDatabase.TYPE; + } + + SQLiteDatabase database = getReadableDatabase(); + String sql = "SELECT " + typeColumn + " FROM " + table + " WHERE " + ID + " = ?"; + String[] args = new String[] { String.valueOf(messageId.getId()) }; + + try (Cursor cursor = database.rawQuery(sql, args)) { + if (cursor != null && cursor.moveToFirst()) { + long type = cursor.getLong(0); + long baseType = type & MmsSmsColumns.Types.BASE_TYPE_MASK; + + if (baseType == MmsSmsColumns.Types.BASE_SENT_TYPE) { + return OutgoingTerminalState.SENT; + } else if (baseType == MmsSmsColumns.Types.BASE_SENT_FAILED_TYPE || + baseType == MmsSmsColumns.Types.BASE_SYNC_FAILED_TYPE) { + return OutgoingTerminalState.FAILED; + } + } + } + + return OutgoingTerminalState.PENDING; + } + private Cursor queryTables( @NonNull String projection, @Nullable String selection, 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 9ec1ee556c..d2cfaf1fc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -259,6 +259,7 @@ public void markAsSentFailed(long id) { updateTypeBitmask(id, Types.BASE_TYPE_MASK, Types.BASE_SENT_FAILED_TYPE); } + public void markAsNotified(long id) { SQLiteDatabase database = getWritableDatabase(); ContentValues contentValues = new ContentValues(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index 2b76664e9a..aca3ad9f29 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -599,6 +599,23 @@ fun DebugMenu( } } + // Debug Logger + DebugCell( + "Messages", + verticalArrangement = Arrangement.spacedBy(0.dp) + ) + { + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + DebugSwitchRow( + text = "Show debug Send Message button", + checked = uiState.debugMessageRampEnabled, + onCheckedChange = { value -> + sendCommand(SetDebugSendMessageRampEnabled(value)) + } + ) + } + DebugCell("Fileserver, avatar & attachment") { Text("Alternative file server") diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index 0b2d3299ce..385f2564f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -35,6 +35,7 @@ import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.notifications.TokenFetcher import org.session.libsession.messaging.sending_receiving.attachments.AttachmentState import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.* import org.session.libsession.utilities.Environment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.upsertContact @@ -149,7 +150,8 @@ class DebugMenuViewModel @AssistedInject constructor( hasCopiedDonationURLDebug = textSecurePreferences.hasCopiedDonationURLDebug() ?: NOT_SET, seenDonateCTAAmountDebug = textSecurePreferences.seenDonationCTAAmountDebug() ?: NOT_SET, showDonateCTAFromPositiveReviewDebug = textSecurePreferences.showDonationCTAFromPositiveReviewDebug() ?: NOT_SET, - userConvoV3 = preferenceStorage[useConvoV3] + userConvoV3 = preferenceStorage[useConvoV3], + debugMessageRampEnabled = textSecurePreferences.isDebugSendMessageRampEnabled() ) ) val uiState: StateFlow @@ -527,6 +529,11 @@ class DebugMenuViewModel @AssistedInject constructor( it.copy(userConvoV3 = command.use) } } + + is Commands.SetDebugSendMessageRampEnabled -> { + textSecurePreferences.setDebugSendMessageRampEnabled(command.value) + _uiState.update { it.copy(debugMessageRampEnabled = command.value) } + } } } @@ -658,7 +665,8 @@ class DebugMenuViewModel @AssistedInject constructor( val hasCopiedDonationURLDebug: String, val seenDonateCTAAmountDebug: String, val showDonateCTAFromPositiveReviewDebug: String, - val userConvoV3: Boolean + val userConvoV3: Boolean, + val debugMessageRampEnabled: Boolean = false ) enum class DatabaseInspectorState { @@ -728,6 +736,7 @@ class DebugMenuViewModel @AssistedInject constructor( data class SetDebugDonationCTAViews(val value: String) : Commands() data class SetDebugShowDonationFromReview(val value: String) : Commands() data class UseConvoV3(val use: Boolean) : Commands() + data class SetDebugSendMessageRampEnabled(val value: Boolean) : Commands() } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt index 5379f4f6c0..bbf18e9630 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.kt @@ -238,7 +238,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderCompos .commit() } - override fun onSendClicked(media: List, message: String) { + override fun onSendClicked(media: List, message: String, isDebug : Boolean) { viewModel.onSendClicked() val mediaList = ArrayList(media) @@ -246,6 +246,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderCompos intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList) intent.putExtra(EXTRA_MESSAGE, message) + intent.putExtra(EXTRA_DEBUG_UPLOAD, isDebug) setResult(RESULT_OK, intent) finish() @@ -577,6 +578,7 @@ class MediaSendActivity : ScreenLockActionBarActivity(), MediaPickerFolderCompos const val EXTRA_MEDIA: String = "media" const val EXTRA_MESSAGE: String = "message" + const val EXTRA_DEBUG_UPLOAD: String = "debug_upload_attachments" private const val KEY_ADDRESS = "address" private const val KEY_BODY = "body" diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt index 31a0f8ae48..539c34c833 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendFragment.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.future.await import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext +import network.loki.messenger.BuildConfig import network.loki.messenger.databinding.MediasendFragmentBinding import org.session.libsession.utilities.Address import org.session.libsession.utilities.MediaTypes @@ -253,7 +254,7 @@ class MediaSendFragment : Fragment(), RailItemListener, InputBarDelegate { } } - private fun processMedia(mediaList: List, savedState: Map) { + private fun processMedia(mediaList: List, savedState: Map, isDebug : Boolean = false) { val binding = binding ?: return // If the view is destroyed, this process should not continue val context = requireContext().applicationContext @@ -357,7 +358,12 @@ class MediaSendFragment : Fragment(), RailItemListener, InputBarDelegate { } } - controller.onSendClicked(updatedMedia, mentionViewModel.normalizeMessageBody()) + controller.onSendClicked( + updatedMedia, + mentionViewModel.normalizeMessageBody(), + isDebug && BuildConfig.DEBUG + ) + delayedShowLoader.cancel() binding.loader.isVisible = false binding.inputBar.clearFocus() @@ -383,6 +389,12 @@ class MediaSendFragment : Fragment(), RailItemListener, InputBarDelegate { fragmentPagerAdapter?.let { processMedia(it.allMedia, it.savedState) } } + override fun sendDebugMessage() { + if(viewModel == null || viewModel?.validateMessageLength() == false) return + + fragmentPagerAdapter?.let { processMedia(it.allMedia, it.savedState, isDebug = true) } + } + override fun onCharLimitTapped() { viewModel?.onCharLimitTapped() } @@ -404,7 +416,7 @@ class MediaSendFragment : Fragment(), RailItemListener, InputBarDelegate { interface Controller { fun onAddMediaClicked(bucketId: String) - fun onSendClicked(media: List, body: String) + fun onSendClicked(media: List, body: String, isDebug: Boolean) } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt index ba2154d0c9..5426c5482b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/loadaccount/LoadAccount.kt @@ -45,7 +45,7 @@ internal fun LoadAccountScreen( ) { val pagerState = rememberPagerState { TITLES.size } - Scaffold { paddingValues -> + Scaffold{ paddingValues -> Column { SessionTabRow(pagerState, TITLES) HorizontalPager( @@ -54,7 +54,8 @@ internal fun LoadAccountScreen( ) { page -> when (TITLES[page]) { R.string.sessionRecoveryPassword -> RecoveryPassword( - modifier = Modifier.padding(bottom = paddingValues.calculateBottomPadding()) + modifier = Modifier + .padding(bottom = paddingValues.calculateBottomPadding()) .consumeWindowInsets(paddingValues), state = state, onChange = onChange, @@ -115,7 +116,8 @@ private fun RecoveryPassword( SessionOutlinedTextField( text = state.recoveryPhrase, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .qaTag(R.string.AccessibilityId_recoveryPasswordEnter), placeholder = stringResource(R.string.recoveryPasswordEnter), onChange = onChange, diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt index 8b3bcd7060..34b1d91840 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.onboarding.messagenotifications -import android.R.attr.checked -import android.R.attr.onClick import androidx.annotation.StringRes import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -69,7 +67,7 @@ internal fun MessageNotificationsScreen( val scroll = rememberScrollState() - Column(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()){ Box( modifier = Modifier .weight(1f) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt index 6efe79e888..b9c7774ff1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt @@ -17,6 +17,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.mapNotNull import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol @@ -153,9 +154,16 @@ class FetchProDetailsWorker @AssistedInject constructor( .fromUniqueWorkNames(listOf(TAG)) .build() - return WorkManager.getInstance(context) - .getWorkInfosFlow(workQuery) - .mapNotNull { it.firstOrNull() } + return try { + WorkManager.getInstance(context) + .getWorkInfosFlow(workQuery) + .mapNotNull { it.firstOrNull() } + } catch (e: IllegalStateException) { + // In certain test setups WorkManager is not manually initialized. + // Avoid crashing observers, callers can initialize WorkManager in tests if they need real emissions. + Log.w(TAG, "WorkManager not initialized; returning empty flow from FetchProDetailsWorker.watch()", e) + emptyFlow() + } } fun schedule( diff --git a/app/src/main/res/layout/view_input_bar.xml b/app/src/main/res/layout/view_input_bar.xml index 86ff0aabfc..f07a4eafae 100644 --- a/app/src/main/res/layout/view_input_bar.xml +++ b/app/src/main/res/layout/view_input_bar.xml @@ -45,7 +45,7 @@ android:layout_height="wrap_content" android:layout_centerInParent="true" android:layout_toEndOf="@+id/attachmentsButtonContainer" - android:layout_toStartOf="@+id/microphoneOrSendButtonContainer" + android:layout_toStartOf="@+id/debugButtonContainer" android:layout_marginHorizontal="16dp" android:background="@null" android:contentDescription="@string/AccessibilityId_inputBox" @@ -94,6 +94,16 @@ android:gravity="bottom|center_horizontal"/> + +