mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-20 19:18:37 +00:00
Add data seeding playground.
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
)
|
||||
@@ -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. */
|
||||
|
||||
@@ -791,6 +791,9 @@
|
||||
<action
|
||||
android:id="@+id/action_internalSettingsFragment_to_internalSqlitePlaygroundFragment"
|
||||
app:destination="@id/internalSqlitePlaygroundFragment" />
|
||||
<action
|
||||
android:id="@+id/action_internalSettingsFragment_to_dataSeedingPlaygroundFragment"
|
||||
app:destination="@id/dataSeedingPlaygroundFragment" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
@@ -853,6 +856,11 @@
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.internal.sqlite.InternalSqlitePlaygroundFragment"
|
||||
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 -->
|
||||
|
||||
<!-- App updates -->
|
||||
|
||||
Reference in New Issue
Block a user