From 997a270a84ee7269c4d0dfb58968aaa3d83ba749 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 02:30:33 +0000 Subject: [PATCH 1/3] feat: add export button to relay settings screen Adds a share icon button in the top bar of the relay settings screen that exports all relay configurations (except Connected Relays) as human-readable text via Android's share intent. Each section includes a header and description, with one relay URL per line. https://claude.ai/code/session_013PhuahpBFh6djVHzSsmNKM --- .../ui/navigation/topbars/ActionTopBar.kt | 6 + .../loggedIn/relays/AllRelayListScreen.kt | 193 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 201 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt index a16b5925e..a49514dc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/ActionTopBar.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 508c8ae09..ef41607de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays +import android.content.Context +import android.content.Intent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets @@ -27,7 +29,11 @@ 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.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 @@ -36,6 +42,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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 +84,7 @@ 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.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.theme.grayText @Composable @@ -175,10 +183,36 @@ fun MappedAllRelayListView( val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() val relayFeedsFeedState by relayFeedsViewModel.relays.collectAsStateWithLifecycle() + val context = LocalContext.current + Scaffold( topBar = { SavingTopBar( titleRes = R.string.relay_settings, + additionalActions = { + IconButton(onClick = { + exportRelaySettings( + context = context, + homeRelays = homeFeedState, + notifRelays = notifFeedState, + dmRelays = dmFeedState, + privateOutboxRelays = privateOutboxFeedState, + proxyRelays = proxyRelays, + broadcastRelays = broadcastRelays, + indexerRelays = indexerRelays, + searchRelays = searchFeedState, + localRelays = localFeedState, + trustedRelays = trustedFeedState, + favoriteRelays = relayFeedsFeedState, + blockedRelays = blockedFeedState, + ) + }) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringRes(R.string.export_relay_settings), + ) + } + }, onCancel = { dmViewModel.clear() nip65ViewModel.clear() @@ -447,3 +481,162 @@ fun SettingsCategoryWithButton( action() } } + +private fun formatRelaySection( + title: String, + description: String, + relays: List, + builder: StringBuilder, +) { + if (relays.isEmpty()) return + builder.appendLine("## $title") + builder.appendLine("# $description") + builder.appendLine() + relays.forEach { relay -> + builder.appendLine(relay.relay.url) + } + builder.appendLine() +} + +fun buildRelaySettingsText( + context: Context, + homeRelays: List, + notifRelays: List, + dmRelays: List, + privateOutboxRelays: List, + proxyRelays: List, + broadcastRelays: List, + indexerRelays: List, + searchRelays: List, + localRelays: List, + trustedRelays: List, + favoriteRelays: List, + blockedRelays: List, +): String { + val builder = StringBuilder() + builder.appendLine("# ${context.getString(R.string.relay_settings)}") + builder.appendLine() + + formatRelaySection( + context.getString(R.string.public_home_section), + context.getString(R.string.public_home_section_explainer), + homeRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.public_notif_section), + context.getString(R.string.public_notif_section_explainer), + notifRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.private_inbox_section), + context.getString(R.string.private_inbox_section_explainer), + dmRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.private_outbox_section), + context.getString(R.string.private_outbox_section_explainer), + privateOutboxRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.proxy_section), + context.getString(R.string.proxy_section_explainer), + proxyRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.broadcast_section), + context.getString(R.string.broadcast_section_explainer), + broadcastRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.indexer_section), + context.getString(R.string.indexer_section_explainer), + indexerRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.search_section), + context.getString(R.string.search_section_explainer), + searchRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.local_section), + context.getString(R.string.local_section_explainer), + localRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.trusted_section), + context.getString(R.string.trusted_section_explainer), + trustedRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.favorite_section), + context.getString(R.string.favorite_section_explainer), + favoriteRelays, + builder, + ) + formatRelaySection( + context.getString(R.string.blocked_section), + context.getString(R.string.blocked_section_explainer), + blockedRelays, + builder, + ) + + return builder.toString().trimEnd() +} + +fun exportRelaySettings( + context: Context, + homeRelays: List, + notifRelays: List, + dmRelays: List, + privateOutboxRelays: List, + proxyRelays: List, + broadcastRelays: List, + indexerRelays: List, + searchRelays: List, + localRelays: List, + trustedRelays: List, + favoriteRelays: List, + blockedRelays: List, +) { + val text = + buildRelaySettingsText( + context, + homeRelays, + notifRelays, + dmRelays, + privateOutboxRelays, + proxyRelays, + broadcastRelays, + indexerRelays, + searchRelays, + localRelays, + trustedRelays, + favoriteRelays, + blockedRelays, + ) + + 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) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 72f3724dc..02349fa0d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1379,6 +1379,8 @@ Blocked Relays Amethyst will never connect to these relays + Export relay settings + Zap the Devs! Your donation helps us make a difference. Every sat counts! Donate Now From e83ec94461c6bc8686040ed26b66cbab6143ba77 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 17:28:39 +0000 Subject: [PATCH 2/3] refactor: extract relay export logic into RelayExporter class Moves the text formatting and share intent logic from AllRelayListScreen into a dedicated RelayExporter class in the common package. https://claude.ai/code/session_013PhuahpBFh6djVHzSsmNKM --- .../loggedIn/relays/AllRelayListScreen.kt | 165 +----------------- .../loggedIn/relays/common/RelayExporter.kt | 125 +++++++++++++ 2 files changed, 127 insertions(+), 163 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index ef41607de..7a0902a2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays -import android.content.Context -import android.content.Intent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets @@ -84,7 +82,7 @@ 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.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayExporter import com.vitorpamplona.amethyst.ui.theme.grayText @Composable @@ -191,8 +189,7 @@ fun MappedAllRelayListView( titleRes = R.string.relay_settings, additionalActions = { IconButton(onClick = { - exportRelaySettings( - context = context, + RelayExporter(context).export( homeRelays = homeFeedState, notifRelays = notifFeedState, dmRelays = dmFeedState, @@ -482,161 +479,3 @@ fun SettingsCategoryWithButton( } } -private fun formatRelaySection( - title: String, - description: String, - relays: List, - builder: StringBuilder, -) { - if (relays.isEmpty()) return - builder.appendLine("## $title") - builder.appendLine("# $description") - builder.appendLine() - relays.forEach { relay -> - builder.appendLine(relay.relay.url) - } - builder.appendLine() -} - -fun buildRelaySettingsText( - context: Context, - homeRelays: List, - notifRelays: List, - dmRelays: List, - privateOutboxRelays: List, - proxyRelays: List, - broadcastRelays: List, - indexerRelays: List, - searchRelays: List, - localRelays: List, - trustedRelays: List, - favoriteRelays: List, - blockedRelays: List, -): String { - val builder = StringBuilder() - builder.appendLine("# ${context.getString(R.string.relay_settings)}") - builder.appendLine() - - formatRelaySection( - context.getString(R.string.public_home_section), - context.getString(R.string.public_home_section_explainer), - homeRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.public_notif_section), - context.getString(R.string.public_notif_section_explainer), - notifRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.private_inbox_section), - context.getString(R.string.private_inbox_section_explainer), - dmRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.private_outbox_section), - context.getString(R.string.private_outbox_section_explainer), - privateOutboxRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.proxy_section), - context.getString(R.string.proxy_section_explainer), - proxyRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.broadcast_section), - context.getString(R.string.broadcast_section_explainer), - broadcastRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.indexer_section), - context.getString(R.string.indexer_section_explainer), - indexerRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.search_section), - context.getString(R.string.search_section_explainer), - searchRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.local_section), - context.getString(R.string.local_section_explainer), - localRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.trusted_section), - context.getString(R.string.trusted_section_explainer), - trustedRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.favorite_section), - context.getString(R.string.favorite_section_explainer), - favoriteRelays, - builder, - ) - formatRelaySection( - context.getString(R.string.blocked_section), - context.getString(R.string.blocked_section_explainer), - blockedRelays, - builder, - ) - - return builder.toString().trimEnd() -} - -fun exportRelaySettings( - context: Context, - homeRelays: List, - notifRelays: List, - dmRelays: List, - privateOutboxRelays: List, - proxyRelays: List, - broadcastRelays: List, - indexerRelays: List, - searchRelays: List, - localRelays: List, - trustedRelays: List, - favoriteRelays: List, - blockedRelays: List, -) { - val text = - buildRelaySettingsText( - context, - homeRelays, - notifRelays, - dmRelays, - privateOutboxRelays, - proxyRelays, - broadcastRelays, - indexerRelays, - searchRelays, - localRelays, - trustedRelays, - favoriteRelays, - blockedRelays, - ) - - 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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt new file mode 100644 index 000000000..947ca078f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt @@ -0,0 +1,125 @@ +/* + * 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( + homeRelays: List, + notifRelays: List, + dmRelays: List, + privateOutboxRelays: List, + proxyRelays: List, + broadcastRelays: List, + indexerRelays: List, + searchRelays: List, + localRelays: List, + trustedRelays: List, + favoriteRelays: List, + blockedRelays: List, + ) { + val text = + buildExportText( + homeRelays, + notifRelays, + dmRelays, + privateOutboxRelays, + proxyRelays, + broadcastRelays, + indexerRelays, + searchRelays, + localRelays, + trustedRelays, + favoriteRelays, + blockedRelays, + ) + + 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( + homeRelays: List, + notifRelays: List, + dmRelays: List, + privateOutboxRelays: List, + proxyRelays: List, + broadcastRelays: List, + indexerRelays: List, + searchRelays: List, + localRelays: List, + trustedRelays: List, + favoriteRelays: List, + blockedRelays: List, + ): String { + val builder = StringBuilder() + builder.appendLine("# ${context.getString(R.string.relay_settings)}") + builder.appendLine() + + formatSection(R.string.public_home_section, R.string.public_home_section_explainer, homeRelays, builder) + formatSection(R.string.public_notif_section, R.string.public_notif_section_explainer, notifRelays, builder) + formatSection(R.string.private_inbox_section, R.string.private_inbox_section_explainer, dmRelays, builder) + formatSection(R.string.private_outbox_section, R.string.private_outbox_section_explainer, privateOutboxRelays, builder) + formatSection(R.string.proxy_section, R.string.proxy_section_explainer, proxyRelays, builder) + formatSection(R.string.broadcast_section, R.string.broadcast_section_explainer, broadcastRelays, builder) + formatSection(R.string.indexer_section, R.string.indexer_section_explainer, indexerRelays, builder) + formatSection(R.string.search_section, R.string.search_section_explainer, searchRelays, builder) + formatSection(R.string.local_section, R.string.local_section_explainer, localRelays, builder) + formatSection(R.string.trusted_section, R.string.trusted_section_explainer, trustedRelays, builder) + formatSection(R.string.favorite_section, R.string.favorite_section_explainer, favoriteRelays, builder) + formatSection(R.string.blocked_section, R.string.blocked_section_explainer, blockedRelays, builder) + + return builder.toString().trimEnd() + } + + private fun formatSection( + titleRes: Int, + descriptionRes: Int, + relays: List, + builder: StringBuilder, + ) { + if (relays.isEmpty()) return + builder.appendLine("## ${context.getString(titleRes)}") + builder.appendLine("# ${context.getString(descriptionRes)}") + builder.appendLine() + relays.forEach { relay -> + builder.appendLine(relay.relay.url) + } + builder.appendLine() + } +} From 0ad63bbfc11760b51527070c00e1de775b3594c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 13 Mar 2026 18:24:05 +0000 Subject: [PATCH 3/3] feat: add ZIP/JSON relay export and format picker dropdown - Create RelayListCollection to bundle all relay lists with section metadata - Create RelayZipExporter that exports each relay category as a separate JSON file (array of relay URL strings) inside a zip archive, shared via FileProvider - Refactor RelayExporter to use RelayListCollection - Replace single export button with a dropdown menu letting users pick between text export and ZIP (JSON) export https://claude.ai/code/session_013PhuahpBFh6djVHzSsmNKM --- .../loggedIn/relays/AllRelayListScreen.kt | 79 +++++++++++----- .../loggedIn/relays/common/RelayExporter.kt | 73 +++------------ .../relays/common/RelayListCollection.kt | 61 +++++++++++++ .../relays/common/RelayZipExporter.kt | 89 +++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 5 files changed, 221 insertions(+), 83 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayListCollection.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayZipExporter.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 7a0902a2b..49cd1508e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -29,6 +29,8 @@ 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 @@ -39,6 +41,9 @@ 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 @@ -83,6 +88,8 @@ 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 @@ -183,32 +190,28 @@ fun MappedAllRelayListView( 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 = { - IconButton(onClick = { - RelayExporter(context).export( - homeRelays = homeFeedState, - notifRelays = notifFeedState, - dmRelays = dmFeedState, - privateOutboxRelays = privateOutboxFeedState, - proxyRelays = proxyRelays, - broadcastRelays = broadcastRelays, - indexerRelays = indexerRelays, - searchRelays = searchFeedState, - localRelays = localFeedState, - trustedRelays = trustedFeedState, - favoriteRelays = relayFeedsFeedState, - blockedRelays = blockedFeedState, - ) - }) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = stringRes(R.string.export_relay_settings), - ) - } + ExportDropdownMenu(context, collection) }, onCancel = { dmViewModel.clear() @@ -479,3 +482,37 @@ fun SettingsCategoryWithButton( } } +@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) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt index 947ca078f..ff226f4c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt @@ -27,35 +27,8 @@ import com.vitorpamplona.amethyst.R class RelayExporter( val context: Context, ) { - fun export( - homeRelays: List, - notifRelays: List, - dmRelays: List, - privateOutboxRelays: List, - proxyRelays: List, - broadcastRelays: List, - indexerRelays: List, - searchRelays: List, - localRelays: List, - trustedRelays: List, - favoriteRelays: List, - blockedRelays: List, - ) { - val text = - buildExportText( - homeRelays, - notifRelays, - dmRelays, - privateOutboxRelays, - proxyRelays, - broadcastRelays, - indexerRelays, - searchRelays, - localRelays, - trustedRelays, - favoriteRelays, - blockedRelays, - ) + fun export(collection: RelayListCollection) { + val text = buildExportText(collection) val sendIntent = Intent().apply { @@ -73,51 +46,27 @@ class RelayExporter( context.startActivity(shareIntent) } - fun buildExportText( - homeRelays: List, - notifRelays: List, - dmRelays: List, - privateOutboxRelays: List, - proxyRelays: List, - broadcastRelays: List, - indexerRelays: List, - searchRelays: List, - localRelays: List, - trustedRelays: List, - favoriteRelays: List, - blockedRelays: List, - ): String { + fun buildExportText(collection: RelayListCollection): String { val builder = StringBuilder() builder.appendLine("# ${context.getString(R.string.relay_settings)}") builder.appendLine() - formatSection(R.string.public_home_section, R.string.public_home_section_explainer, homeRelays, builder) - formatSection(R.string.public_notif_section, R.string.public_notif_section_explainer, notifRelays, builder) - formatSection(R.string.private_inbox_section, R.string.private_inbox_section_explainer, dmRelays, builder) - formatSection(R.string.private_outbox_section, R.string.private_outbox_section_explainer, privateOutboxRelays, builder) - formatSection(R.string.proxy_section, R.string.proxy_section_explainer, proxyRelays, builder) - formatSection(R.string.broadcast_section, R.string.broadcast_section_explainer, broadcastRelays, builder) - formatSection(R.string.indexer_section, R.string.indexer_section_explainer, indexerRelays, builder) - formatSection(R.string.search_section, R.string.search_section_explainer, searchRelays, builder) - formatSection(R.string.local_section, R.string.local_section_explainer, localRelays, builder) - formatSection(R.string.trusted_section, R.string.trusted_section_explainer, trustedRelays, builder) - formatSection(R.string.favorite_section, R.string.favorite_section_explainer, favoriteRelays, builder) - formatSection(R.string.blocked_section, R.string.blocked_section_explainer, blockedRelays, builder) + collection.sections().forEach { section -> + formatSection(section, builder) + } return builder.toString().trimEnd() } private fun formatSection( - titleRes: Int, - descriptionRes: Int, - relays: List, + section: RelaySection, builder: StringBuilder, ) { - if (relays.isEmpty()) return - builder.appendLine("## ${context.getString(titleRes)}") - builder.appendLine("# ${context.getString(descriptionRes)}") + if (section.relays.isEmpty()) return + builder.appendLine("## ${context.getString(section.titleRes)}") + builder.appendLine("# ${context.getString(section.descriptionRes)}") builder.appendLine() - relays.forEach { relay -> + section.relays.forEach { relay -> builder.appendLine(relay.relay.url) } builder.appendLine() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayListCollection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayListCollection.kt new file mode 100644 index 000000000..126f208a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayListCollection.kt @@ -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, + val notifRelays: List, + val dmRelays: List, + val privateOutboxRelays: List, + val proxyRelays: List, + val broadcastRelays: List, + val indexerRelays: List, + val searchRelays: List, + val localRelays: List, + val trustedRelays: List, + val favoriteRelays: List, + val blockedRelays: List, +) { + fun sections(): List = + 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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayZipExporter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayZipExporter.kt new file mode 100644 index 000000000..d1613f5e3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayZipExporter.kt @@ -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): 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() + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 02349fa0d..9270c95cb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1380,6 +1380,8 @@ Amethyst will never connect to these relays Export relay settings + Export as text + Export as ZIP (JSON) Zap the Devs! Your donation helps us make a difference. Every sat counts!