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..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 @@ -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) + }, + ) + } +} 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..ff226f4c8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayExporter.kt @@ -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() + } +} 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 013a1634b..8fd95a069 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1379,6 +1379,10 @@ Blocked Relays 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! Donate Now