diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index c2e735e302..617f99133e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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() sectionHeaderPref(DSLSettingsText.from("Miscellaneous")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/dataseeding/DataSeedingPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/dataseeding/DataSeedingPlaygroundFragment.kt new file mode 100644 index 0000000000..a2a6b7f8a7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/dataseeding/DataSeedingPlaygroundFragment.kt @@ -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 + + 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" + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/dataseeding/DataSeedingPlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/dataseeding/DataSeedingPlaygroundViewModel.kt new file mode 100644 index 0000000000..5448be0694 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/dataseeding/DataSeedingPlaygroundViewModel.kt @@ -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 = _state.asStateFlow() + + fun loadThreads() { + viewModelScope.launch(Dispatchers.IO) { + try { + val threads = mutableListOf() + 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() + 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() + 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 { + val mediaFiles = mutableListOf() + + 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 = emptyList(), + val selectedThreads: Set = emptySet(), + val mediaFiles: List = emptyList(), + val selectedFolderPath: String = "" +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index aa17207449..21541ba1fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -535,7 +535,7 @@ object RemoteConfig { key = "android.internalUser", hotSwappable = true ) { 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. */ diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index bc0127110e..b6b356f2b8 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -791,6 +791,9 @@ + + +