Merge pull request #1823 from vitorpamplona/claude/export-relay-settings-v8uQg
Add relay settings export functionality
This commit is contained in:
+6
@@ -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)
|
||||
|
||||
+69
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+74
@@ -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()
|
||||
}
|
||||
}
|
||||
+61
@@ -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>,
|
||||
)
|
||||
+89
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user