Add data seeding playground.

This commit is contained in:
Greyson Parrelli
2025-09-08 08:58:44 -04:00
parent f88181cc82
commit d8758bcc4e
5 changed files with 589 additions and 1 deletions

View File

@@ -218,6 +218,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
} }
) )
clickPref(
title = DSLSettingsText.from("Data Seeding Playground"),
summary = DSLSettingsText.from("Seed conversations with media files from a folder."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDataSeedingPlaygroundFragment())
}
)
dividerPref() dividerPref()
sectionHeaderPref(DSLSettingsText.from("Miscellaneous")) sectionHeaderPref(DSLSettingsText.from("Miscellaneous"))

View File

@@ -0,0 +1,352 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.dataseeding
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.ThreadRecord
class DataSeedingPlaygroundFragment : ComposeFragment() {
private val viewModel: DataSeedingPlaygroundViewModel by viewModels()
private lateinit var selectFolderLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
selectFolderLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
viewModel.selectFolder(uri)
} ?: Toast.makeText(requireContext(), "No folder selected", Toast.LENGTH_SHORT).show()
}
}
}
@Composable
override fun FragmentContent() {
val context = LocalContext.current
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loadThreads()
}
Screen(
state = state,
onBack = { findNavController().popBackStack() },
onSelectFolderClicked = {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
selectFolderLauncher.launch(intent)
},
onThreadSelectionChanged = { threadId, isSelected ->
viewModel.toggleThreadSelection(threadId, isSelected)
},
onSeedDataClicked = {
viewModel.seedData(
onComplete = {
Toast.makeText(context, "Data seeding completed!", Toast.LENGTH_SHORT).show()
},
onError = { error ->
Toast.makeText(context, "Error: $error", Toast.LENGTH_LONG).show()
}
)
}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Screen(
state: DataSeedingPlaygroundState,
onBack: () -> Unit = {},
onSelectFolderClicked: () -> Unit = {},
onThreadSelectionChanged: (Long, Boolean) -> Unit = { _, _ -> },
onSeedDataClicked: () -> Unit = {}
) {
var showConfirmDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = {
Text("Data Seeding Playground")
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.symbol_arrow_start_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
}
}
)
}
) { paddingValues ->
Surface(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Folder selection section
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Media Folder",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
if (state.selectedFolderPath.isNotEmpty()) {
Text(
text = "Selected: ${state.selectedFolderPath}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Media files found: ${state.mediaFiles.size}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "No folder selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Rows.TextRow(
text = "Select Media Folder",
label = "Choose a folder containing photos and videos to seed into conversations.",
onClick = onSelectFolderClicked
)
}
}
// Thread selection section
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Conversation Threads (${state.selectedThreads.size} selected)",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.height(300.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(state.threads) { thread ->
ThreadSelectionRow(
thread = thread,
isSelected = state.selectedThreads.contains(thread.threadId),
onSelectionChanged = { isSelected ->
onThreadSelectionChanged(thread.threadId, isSelected)
}
)
}
}
}
}
// Action section
if (state.mediaFiles.isNotEmpty() && state.selectedThreads.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Ready to Seed Data",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This will send ${state.mediaFiles.size} media files to ${state.selectedThreads.size} conversations in a round-robin fashion.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Rows.TextRow(
text = "Seed Data",
label = "Send the selected media files to the selected conversations.",
onClick = {
showConfirmDialog = true
}
)
}
}
}
}
}
}
// Confirmation dialog
if (showConfirmDialog) {
AlertDialog(
onDismissRequest = { showConfirmDialog = false },
title = { Text("Confirm Data Seeding") },
text = {
Text("Are you sure you want to send ${state.mediaFiles.size} media files to ${state.selectedThreads.size} conversations? This action cannot be undone.")
},
confirmButton = {
TextButton(
onClick = {
showConfirmDialog = false
onSeedDataClicked()
}
) {
Text("Seed Data")
}
},
dismissButton = {
TextButton(
onClick = { showConfirmDialog = false }
) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun ThreadSelectionRow(
thread: ThreadRecord,
isSelected: Boolean,
onSelectionChanged: (Boolean) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isSelected,
onCheckedChange = onSelectionChanged
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text(
text = thread.recipient.getDisplayName(LocalContext.current),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
if (thread.body.isNotEmpty()) {
Text(
text = thread.body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
}
}
}
@SignalPreview
@Composable
fun PreviewScreen() {
Previews.Preview {
Screen(
state = DataSeedingPlaygroundState(
threads = emptyList(),
selectedThreads = emptySet(),
mediaFiles = emptyList(),
selectedFolderPath = "/storage/emulated/0/Pictures"
)
)
}
}
@SignalPreview
@Composable
fun PreviewScreenWithData() {
Previews.Preview {
Screen(
state = DataSeedingPlaygroundState(
threads = emptyList(),
selectedThreads = setOf(1L, 2L),
mediaFiles = listOf("photo1.jpg", "video1.mp4", "photo2.jpg"),
selectedFolderPath = "/storage/emulated/0/Pictures"
)
)
}
}

View File

@@ -0,0 +1,220 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.dataseeding
import android.app.Application
import android.content.Context
import android.database.Cursor
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.MediaUtil
class DataSeedingPlaygroundViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private val TAG = Log.tag(DataSeedingPlaygroundViewModel::class.java)
private const val MAX_RECENT_THREADS = 10
}
private val _state = MutableStateFlow(DataSeedingPlaygroundState())
val state: StateFlow<DataSeedingPlaygroundState> = _state.asStateFlow()
fun loadThreads() {
viewModelScope.launch(Dispatchers.IO) {
try {
val threads = mutableListOf<ThreadRecord>()
val cursor: Cursor = SignalDatabase.threads.getRecentConversationList(
limit = MAX_RECENT_THREADS,
includeInactiveGroups = false,
hideV1Groups = true
)
cursor.use {
val reader = SignalDatabase.threads.readerFor(it)
var threadRecord = reader.getNext()
while (threadRecord != null) {
threads.add(threadRecord)
threadRecord = reader.getNext()
}
}
_state.value = _state.value.copy(threads = threads)
} catch (e: Exception) {
Log.w(TAG, "Failed to load threads", e)
}
}
}
fun selectFolder(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
try {
val context = getApplication<Application>()
val documentFile = DocumentFile.fromTreeUri(context, uri)
if (documentFile != null && documentFile.isDirectory) {
val mediaFiles = findMediaFiles(documentFile)
_state.value = _state.value.copy(
selectedFolderPath = documentFile.uri.toString(),
mediaFiles = mediaFiles
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to select folder", e)
}
}
}
fun toggleThreadSelection(threadId: Long, isSelected: Boolean) {
val selectedThreads = _state.value.selectedThreads.toMutableSet()
if (isSelected) {
selectedThreads.add(threadId)
} else {
selectedThreads.remove(threadId)
}
_state.value = _state.value.copy(selectedThreads = selectedThreads)
}
@OptIn(DelicateCoroutinesApi::class)
fun seedData(onComplete: () -> Unit, onError: (String) -> Unit) {
GlobalScope.launch(Dispatchers.IO) {
try {
val context = getApplication<Application>()
val currentState = _state.value
if (currentState.mediaFiles.isEmpty()) {
withContext(Dispatchers.Main) {
onError("No media files selected")
}
return@launch
}
if (currentState.selectedThreads.isEmpty()) {
withContext(Dispatchers.Main) {
onError("No threads selected")
}
return@launch
}
val mediaFiles = currentState.mediaFiles
val threadIds = currentState.selectedThreads.toList()
var currentThreadIndex = 0
for (mediaFile in mediaFiles) {
val threadId = threadIds[currentThreadIndex % threadIds.size]
sendMediaToThread(context, mediaFile, threadId)
currentThreadIndex++
}
withContext(Dispatchers.Main) {
onComplete()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to seed data", e)
withContext(Dispatchers.Main) {
onError(e.message ?: "Unknown error")
}
}
}
}
private fun findMediaFiles(directory: DocumentFile): List<String> {
val mediaFiles = mutableListOf<String>()
directory.listFiles().forEach { file ->
if (file.isFile && file.type != null) {
val mimeType = file.type!!
if (MediaUtil.isImageType(mimeType) || MediaUtil.isVideoType(mimeType)) {
mediaFiles.add(file.name ?: "unknown")
}
}
}
return mediaFiles
}
private suspend fun sendMediaToThread(context: Context, mediaFileName: String, threadId: Long) {
try {
// Find the actual file URI
val currentState = _state.value
val documentFile = DocumentFile.fromTreeUri(context, Uri.parse(currentState.selectedFolderPath))
if (documentFile != null) {
val mediaFile = documentFile.listFiles().find { it.name == mediaFileName }
if (mediaFile != null && mediaFile.uri != null) {
val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
if (recipient != null) {
val mimeType = mediaFile.type ?: MediaUtil.getCorrectedMimeType(mediaFileName)
val attachment: Attachment = UriAttachment(
uri = mediaFile.uri,
contentType = mimeType,
transferState = AttachmentTable.TRANSFER_PROGRESS_STARTED,
size = mediaFile.length(),
fileName = mediaFileName,
voiceNote = false,
borderless = false,
videoGif = false,
quote = false,
quoteTargetContentType = null,
caption = null,
stickerLocator = null,
blurHash = null,
audioHash = null,
transformProperties = null
)
val message = OutgoingMessage(
threadRecipient = recipient,
body = "",
attachments = listOf(attachment),
sentTimeMillis = System.currentTimeMillis(),
isSecure = true
)
MessageSender.send(
context,
message,
threadId,
MessageSender.SendType.SIGNAL,
null,
null
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to send media to thread $threadId", e)
}
}
}
data class DataSeedingPlaygroundState(
val threads: List<ThreadRecord> = emptyList(),
val selectedThreads: Set<Long> = emptySet(),
val mediaFiles: List<String> = emptyList(),
val selectedFolderPath: String = ""
)

View File

@@ -535,7 +535,7 @@ object RemoteConfig {
key = "android.internalUser", key = "android.internalUser",
hotSwappable = true hotSwappable = true
) { value -> ) { value ->
value.asBoolean(false) || Environment.IS_NIGHTLY || Environment.IS_STAGING value.asBoolean(false) || Environment.IS_NIGHTLY || Environment.IS_STAGING
} }
/** The raw client expiration JSON string. */ /** The raw client expiration JSON string. */

View File

@@ -791,6 +791,9 @@
<action <action
android:id="@+id/action_internalSettingsFragment_to_internalSqlitePlaygroundFragment" android:id="@+id/action_internalSettingsFragment_to_internalSqlitePlaygroundFragment"
app:destination="@id/internalSqlitePlaygroundFragment" /> app:destination="@id/internalSqlitePlaygroundFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_dataSeedingPlaygroundFragment"
app:destination="@id/dataSeedingPlaygroundFragment" />
</fragment> </fragment>
<fragment <fragment
@@ -853,6 +856,11 @@
android:name="org.thoughtcrime.securesms.components.settings.app.internal.sqlite.InternalSqlitePlaygroundFragment" android:name="org.thoughtcrime.securesms.components.settings.app.internal.sqlite.InternalSqlitePlaygroundFragment"
android:label="internal_sqlite_playground_fragment" /> android:label="internal_sqlite_playground_fragment" />
<fragment
android:id="@+id/dataSeedingPlaygroundFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.dataseeding.DataSeedingPlaygroundFragment"
android:label="data_seeding_playground_fragment" />
<!-- endregion --> <!-- endregion -->
<!-- App updates --> <!-- App updates -->