Merge pull request #1823 from vitorpamplona/claude/export-relay-settings-v8uQg

Add relay settings export functionality
This commit is contained in:
Vitor Pamplona
2026-03-13 15:09:25 -04:00
committed by GitHub
6 changed files with 303 additions and 0 deletions
@@ -43,6 +43,7 @@ fun ActionTopBar(
isActive: () -> Boolean = { true },
onCancel: () -> Unit,
onPost: () -> Unit,
additionalActions: @Composable (() -> Unit)? = null,
) {
ShorterTopAppBar(
title = {
@@ -63,6 +64,9 @@ fun ActionTopBar(
)
},
actions = {
if (additionalActions != null) {
additionalActions()
}
Button(
modifier = HalfHorzPadding,
enabled = isActive(),
@@ -100,12 +104,14 @@ fun SavingTopBar(
isActive: () -> Boolean = { true },
onCancel: () -> Unit,
onPost: () -> Unit,
additionalActions: @Composable (() -> Unit)? = null,
) = ActionTopBar(
titleRes = titleRes,
postRes = R.string.save,
isActive = isActive,
onCancel = onCancel,
onPost = onPost,
additionalActions = additionalActions,
)
@OptIn(ExperimentalMaterial3Api::class)
@@ -27,7 +27,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
@@ -35,7 +41,11 @@ import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -77,6 +87,9 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayExporter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayListCollection
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayZipExporter
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
@@ -175,10 +188,31 @@ fun MappedAllRelayListView(
val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle()
val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle()
val context = LocalContext.current
val collection =
RelayListCollection(
homeRelays = homeFeedState,
notifRelays = notifFeedState,
dmRelays = dmFeedState,
privateOutboxRelays = privateOutboxFeedState,
proxyRelays = proxyRelays,
broadcastRelays = broadcastRelays,
indexerRelays = indexerRelays,
searchRelays = searchFeedState,
localRelays = localFeedState,
trustedRelays = trustedFeedState,
favoriteRelays = relayFeedsFeedState,
blockedRelays = blockedFeedState,
)
Scaffold(
topBar = {
SavingTopBar(
titleRes = R.string.relay_settings,
additionalActions = {
ExportDropdownMenu(context, collection)
},
onCancel = {
dmViewModel.clear()
nip65ViewModel.clear()
@@ -447,3 +481,38 @@ fun SettingsCategoryWithButton(
action()
}
}
@Composable
fun ExportDropdownMenu(
context: android.content.Context,
collection: RelayListCollection,
) {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = true }) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = stringRes(R.string.export_relay_settings),
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text(stringRes(R.string.export_as_text)) },
onClick = {
expanded = false
RelayExporter(context).export(collection)
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.export_as_zip)) },
onClick = {
expanded = false
RelayZipExporter(context).export(collection)
},
)
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.R
class RelayExporter(
val context: Context,
) {
fun export(collection: RelayListCollection) {
val text = buildExportText(collection)
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
putExtra(Intent.EXTRA_TITLE, context.getString(R.string.export_relay_settings))
}
val shareIntent =
Intent.createChooser(
sendIntent,
context.getString(R.string.export_relay_settings),
)
context.startActivity(shareIntent)
}
fun buildExportText(collection: RelayListCollection): String {
val builder = StringBuilder()
builder.appendLine("# ${context.getString(R.string.relay_settings)}")
builder.appendLine()
collection.sections().forEach { section ->
formatSection(section, builder)
}
return builder.toString().trimEnd()
}
private fun formatSection(
section: RelaySection,
builder: StringBuilder,
) {
if (section.relays.isEmpty()) return
builder.appendLine("## ${context.getString(section.titleRes)}")
builder.appendLine("# ${context.getString(section.descriptionRes)}")
builder.appendLine()
section.relays.forEach { relay ->
builder.appendLine(relay.relay.url)
}
builder.appendLine()
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common
import com.vitorpamplona.amethyst.R
data class RelayListCollection(
val homeRelays: List<BasicRelaySetupInfo>,
val notifRelays: List<BasicRelaySetupInfo>,
val dmRelays: List<BasicRelaySetupInfo>,
val privateOutboxRelays: List<BasicRelaySetupInfo>,
val proxyRelays: List<BasicRelaySetupInfo>,
val broadcastRelays: List<BasicRelaySetupInfo>,
val indexerRelays: List<BasicRelaySetupInfo>,
val searchRelays: List<BasicRelaySetupInfo>,
val localRelays: List<BasicRelaySetupInfo>,
val trustedRelays: List<BasicRelaySetupInfo>,
val favoriteRelays: List<BasicRelaySetupInfo>,
val blockedRelays: List<BasicRelaySetupInfo>,
) {
fun sections(): List<RelaySection> =
listOf(
RelaySection("home", R.string.public_home_section, R.string.public_home_section_explainer, homeRelays),
RelaySection("notifications", R.string.public_notif_section, R.string.public_notif_section_explainer, notifRelays),
RelaySection("private_inbox", R.string.private_inbox_section, R.string.private_inbox_section_explainer, dmRelays),
RelaySection("private_outbox", R.string.private_outbox_section, R.string.private_outbox_section_explainer, privateOutboxRelays),
RelaySection("proxy", R.string.proxy_section, R.string.proxy_section_explainer, proxyRelays),
RelaySection("broadcast", R.string.broadcast_section, R.string.broadcast_section_explainer, broadcastRelays),
RelaySection("indexer", R.string.indexer_section, R.string.indexer_section_explainer, indexerRelays),
RelaySection("search", R.string.search_section, R.string.search_section_explainer, searchRelays),
RelaySection("local", R.string.local_section, R.string.local_section_explainer, localRelays),
RelaySection("trusted", R.string.trusted_section, R.string.trusted_section_explainer, trustedRelays),
RelaySection("favorites", R.string.favorite_section, R.string.favorite_section_explainer, favoriteRelays),
RelaySection("blocked", R.string.blocked_section, R.string.blocked_section_explainer, blockedRelays),
)
}
data class RelaySection(
val fileName: String,
val titleRes: Int,
val descriptionRes: Int,
val relays: List<BasicRelaySetupInfo>,
)
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.vitorpamplona.amethyst.R
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class RelayZipExporter(
val context: Context,
) {
fun export(collection: RelayListCollection) {
val zipFile = buildZipFile(collection)
val uri =
FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
zipFile,
)
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_TITLE, context.getString(R.string.export_relay_settings))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val shareIntent =
Intent.createChooser(
sendIntent,
context.getString(R.string.export_relay_settings),
)
context.startActivity(shareIntent)
}
fun buildZipFile(collection: RelayListCollection): File {
val zipFile = File(context.cacheDir, "relay_settings.zip")
ZipOutputStream(FileOutputStream(zipFile)).use { zip ->
collection.sections().forEach { section ->
if (section.relays.isNotEmpty()) {
val json = buildJsonArray(section.relays)
zip.putNextEntry(ZipEntry("${section.fileName}.json"))
zip.write(json.toByteArray())
zip.closeEntry()
}
}
}
return zipFile
}
private fun buildJsonArray(relays: List<BasicRelaySetupInfo>): String {
val builder = StringBuilder()
builder.appendLine("[")
relays.forEachIndexed { index, relay ->
val comma = if (index < relays.size - 1) "," else ""
builder.appendLine(" \"${relay.relay.url}\"$comma")
}
builder.append("]")
return builder.toString()
}
}
+4
View File
@@ -1379,6 +1379,10 @@
<string name="blocked_section">Blocked Relays</string>
<string name="blocked_section_explainer">Amethyst will never connect to these relays</string>
<string name="export_relay_settings">Export relay settings</string>
<string name="export_as_text">Export as text</string>
<string name="export_as_zip">Export as ZIP (JSON)</string>
<string name="zap_the_devs_title">Zap the Devs!</string>
<string name="zap_the_devs_description">Your donation helps us make a difference. Every sat counts!</string>
<string name="donate_now">Donate Now</string>