diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt index cb2a27b..47e3f3e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt @@ -200,10 +200,10 @@ object CrashReportManager { fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() - suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean): File = withContext(Dispatchers.IO) { val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME) cacheDir.mkdirs() - val zipFile = File(cacheDir, "${report.id}.zip") + val zipFile = File(cacheDir, if (useAgeEncryption) "${report.id}.zip.age" else "${report.id}.zip") zipFile.delete() val strippedDir = File(cacheDir, report.id) strippedDir.deleteRecursively() @@ -212,7 +212,11 @@ object CrashReportManager { if (!includeConfig) { File(strippedDir, CONFIG_FILE_NAME).delete() } - Libbox.createZipArchive(strippedDir.path, zipFile.path) + if (!includeLog) { + File(strippedDir, GO_LOG_FILE_NAME).delete() + File(strippedDir, JVM_LOG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path, useAgeEncryption) zipFile } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt index 183b19e..0bd3a18 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt @@ -29,6 +29,7 @@ data class OOMReportFile( enum class Kind { METADATA, CONFIG, + GO_LOG, PROFILE, } } @@ -36,6 +37,7 @@ data class OOMReportFile( object OOMReportManager { private const val METADATA_FILE_NAME = "metadata.json" private const val CONFIG_FILE_NAME = "configuration.json" + private const val GO_LOG_FILE_NAME = "go.log" private const val CMDLINE_FILE_NAME = "cmdline" private const val READ_MARKER_FILE_NAME = ".read" private const val OOM_REPORTS_DIR_NAME = "oom_reports" @@ -86,10 +88,15 @@ object OOMReportManager { if (configFile.exists()) { files.add(OOMReportFile(OOMReportFile.Kind.CONFIG, "Configuration", configFile)) } + val goLogFile = File(report.directory, GO_LOG_FILE_NAME) + if (goLogFile.exists()) { + files.add(OOMReportFile(OOMReportFile.Kind.GO_LOG, "Log", goLogFile)) + } report.directory.listFiles()?.filter { file -> file.isFile && file.name != METADATA_FILE_NAME && file.name != CONFIG_FILE_NAME && + file.name != GO_LOG_FILE_NAME && file.name != CMDLINE_FILE_NAME && file.name != READ_MARKER_FILE_NAME }?.sortedBy { it.name }?.forEach { file -> @@ -133,10 +140,12 @@ object OOMReportManager { fun hasConfigFile(report: OOMReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() - suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + fun hasLogFile(report: OOMReport): Boolean = File(report.directory, GO_LOG_FILE_NAME).exists() + + suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean): File = withContext(Dispatchers.IO) { val cacheDir = File(Application.application.cacheDir, OOM_REPORTS_DIR_NAME) cacheDir.mkdirs() - val zipFile = File(cacheDir, "${report.id}.zip") + val zipFile = File(cacheDir, if (useAgeEncryption) "${report.id}.zip.age" else "${report.id}.zip") zipFile.delete() val strippedDir = File(cacheDir, report.id) strippedDir.deleteRecursively() @@ -145,7 +154,10 @@ object OOMReportManager { if (!includeConfig) { File(strippedDir, CONFIG_FILE_NAME).delete() } - Libbox.createZipArchive(strippedDir.path, zipFile.path) + if (!includeLog) { + File(strippedDir, GO_LOG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path, useAgeEncryption) zipFile } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt index bd7d712..b86a0fb 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt @@ -1,6 +1,8 @@ package io.nekohasekai.sfa.compose.screen.tools import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -23,8 +25,6 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -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 @@ -61,6 +61,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject +import java.io.File import java.text.DateFormat @OptIn(ExperimentalMaterial3Api::class) @@ -70,10 +71,27 @@ fun CrashReportDetailScreen(navController: NavController, reportId: String) { val report = reports.find { it.id == reportId } var files by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } - var shareMenuExpanded by remember { mutableStateOf(false) } + var showShareDialog by remember { mutableStateOf(false) } + var pendingZipFile by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val context = LocalContext.current + val saveLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip"), + ) { uri -> + val zipFile = pendingZipFile + pendingZipFile = null + if (uri != null && zipFile != null) { + scope.launch(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri)?.use { output -> + zipFile.inputStream().use { input -> input.copyTo(output) } + } + } + } + } + } + LaunchedEffect(report) { if (report != null) { withContext(Dispatchers.IO) { @@ -92,13 +110,13 @@ fun CrashReportDetailScreen(navController: NavController, reportId: String) { val hasConfig = report != null && CrashReportManager.hasConfigFile(report) - fun shareReport(includeConfig: Boolean) { + fun shareReport(includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean) { val currentReport = report ?: return scope.launch { - val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig) + val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig, includeLog, useAgeEncryption) val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) val intent = Intent(Intent.ACTION_SEND).apply { - type = "application/zip" + type = if (useAgeEncryption) "application/octet-stream" else "application/zip" putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -116,33 +134,8 @@ fun CrashReportDetailScreen(navController: NavController, reportId: String) { }, actions = { if (!isLoading && files.isNotEmpty()) { - if (hasConfig) { - IconButton(onClick = { shareMenuExpanded = true }) { - Icon(Icons.Default.Share, contentDescription = null) - } - DropdownMenu( - expanded = shareMenuExpanded, - onDismissRequest = { shareMenuExpanded = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.report_share)) }, - onClick = { - shareMenuExpanded = false - shareReport(includeConfig = false) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.report_share_with_config)) }, - onClick = { - shareMenuExpanded = false - shareReport(includeConfig = true) - }, - ) - } - } else { - IconButton(onClick = { shareReport(includeConfig = false) }) { - Icon(Icons.Default.Share, contentDescription = null) - } + IconButton(onClick = { showShareDialog = true }) { + Icon(Icons.Default.Share, contentDescription = null) } IconButton(onClick = { scope.launch { @@ -253,6 +246,25 @@ fun CrashReportDetailScreen(navController: NavController, reportId: String) { } } } + + if (showShareDialog && report != null) { + ReportShareDialog( + hasConfig = hasConfig, + hasLog = false, + onSave = { includeConfig, includeLog, useAgeEncryption -> + showShareDialog = false + scope.launch { + pendingZipFile = CrashReportManager.createZipArchive(report, includeConfig, includeLog, useAgeEncryption) + saveLauncher.launch(if (useAgeEncryption) "${report.id}.zip.age" else "${report.id}.zip") + } + }, + onShare = { includeConfig, includeLog, useAgeEncryption -> + showShareDialog = false + shareReport(includeConfig, includeLog, useAgeEncryption) + }, + onDismiss = { showShareDialog = false }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt index f7936ac..79705c2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt @@ -1,6 +1,8 @@ package io.nekohasekai.sfa.compose.screen.tools import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -16,14 +18,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.DataObject import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -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 @@ -60,6 +61,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject +import java.io.File import java.text.DateFormat @OptIn(ExperimentalMaterial3Api::class) @@ -69,10 +71,27 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) { val report = reports.find { it.id == reportId } var files by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } - var shareMenuExpanded by remember { mutableStateOf(false) } + var showShareDialog by remember { mutableStateOf(false) } + var pendingZipFile by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val context = LocalContext.current + val saveLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip"), + ) { uri -> + val zipFile = pendingZipFile + pendingZipFile = null + if (uri != null && zipFile != null) { + scope.launch(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri)?.use { output -> + zipFile.inputStream().use { input -> input.copyTo(output) } + } + } + } + } + } + LaunchedEffect(report) { if (report != null) { withContext(Dispatchers.IO) { @@ -90,14 +109,15 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) { } val hasConfig = report != null && OOMReportManager.hasConfigFile(report) + val hasLog = report != null && OOMReportManager.hasLogFile(report) - fun shareReport(includeConfig: Boolean) { + fun shareReport(includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean) { val currentReport = report ?: return scope.launch { - val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig) + val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig, includeLog, useAgeEncryption) val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) val intent = Intent(Intent.ACTION_SEND).apply { - type = "application/zip" + type = if (useAgeEncryption) "application/octet-stream" else "application/zip" putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -115,33 +135,8 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) { }, actions = { if (!isLoading && files.isNotEmpty()) { - if (hasConfig) { - IconButton(onClick = { shareMenuExpanded = true }) { - Icon(Icons.Default.Share, contentDescription = null) - } - DropdownMenu( - expanded = shareMenuExpanded, - onDismissRequest = { shareMenuExpanded = false }, - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.report_share)) }, - onClick = { - shareMenuExpanded = false - shareReport(includeConfig = false) - }, - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.report_share_with_config)) }, - onClick = { - shareMenuExpanded = false - shareReport(includeConfig = true) - }, - ) - } - } else { - IconButton(onClick = { shareReport(includeConfig = false) }) { - Icon(Icons.Default.Share, contentDescription = null) - } + IconButton(onClick = { showShareDialog = true }) { + Icon(Icons.Default.Share, contentDescription = null) } IconButton(onClick = { scope.launch { @@ -219,6 +214,7 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) { val icon = when (file.kind) { OOMReportFile.Kind.METADATA -> Icons.Default.DataObject OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings + OOMReportFile.Kind.GO_LOG -> Icons.Default.Description OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal } ListItem( @@ -257,6 +253,25 @@ fun OOMReportDetailScreen(navController: NavController, reportId: String) { } } } + + if (showShareDialog && report != null) { + ReportShareDialog( + hasConfig = hasConfig, + hasLog = hasLog, + onSave = { includeConfig, includeLog, useAgeEncryption -> + showShareDialog = false + scope.launch { + pendingZipFile = OOMReportManager.createZipArchive(report, includeConfig, includeLog, useAgeEncryption) + saveLauncher.launch(if (useAgeEncryption) "${report.id}.zip.age" else "${report.id}.zip") + } + }, + onShare = { includeConfig, includeLog, useAgeEncryption -> + showShareDialog = false + shareReport(includeConfig, includeLog, useAgeEncryption) + }, + onDismiss = { showShareDialog = false }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ReportShareDialog.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ReportShareDialog.kt new file mode 100644 index 0000000..639af6f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ReportShareDialog.kt @@ -0,0 +1,113 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.unit.dp +import dev.jeziellago.compose.markdowntext.MarkdownText +import io.nekohasekai.sfa.R + +@Composable +fun ReportShareDialog( + hasConfig: Boolean, + hasLog: Boolean, + onSave: (includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean) -> Unit, + onShare: (includeConfig: Boolean, includeLog: Boolean, useAgeEncryption: Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var includeConfig by remember { mutableStateOf(false) } + var includeLog by remember { mutableStateOf(true) } + var useAgeEncryption by remember { mutableStateOf(false) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.report_share)) }, + text = { + Column { + if (hasLog) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.report_with_log), + modifier = Modifier.weight(1f), + ) + Switch(checked = includeLog, onCheckedChange = { includeLog = it }) + } + } + if (hasConfig) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.report_with_config), + modifier = Modifier.weight(1f), + ) + Switch(checked = includeConfig, onCheckedChange = { includeConfig = it }) + } + } + if (hasConfig || hasLog) { + Text( + text = if (hasLog) { + stringResource(R.string.report_share_privacy_warning) + } else { + stringResource(R.string.report_share_privacy_warning_config) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp), + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.report_with_age_encryption), + modifier = Modifier.weight(1f), + ) + Switch(checked = useAgeEncryption, onCheckedChange = { useAgeEncryption = it }) + } + MarkdownText( + markdown = stringResource(R.string.report_age_encryption_description), + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + }, + confirmButton = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = { onSave(includeConfig, includeLog, useAgeEncryption) }) { + Text(stringResource(R.string.save)) + } + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + TextButton(onClick = { onShare(includeConfig, includeLog, useAgeEncryption) }) { + Text(stringResource(R.string.report_share)) + } + } + }, + ) +} diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 8d02cca..0faf96f 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -537,6 +537,12 @@ حذف اشتراک‌گذاری اشتراک‌گذاری با پیکربندی + همراه با پیکربندی + همراه با گزارش + فایل‌های گزارش و پیکربندی ممکن است حاوی محتوای خصوصی باشند و نباید عمومی شوند. + فایل‌های پیکربندی ممکن است حاوی محتوای خصوصی باشند و نباید عمومی شوند. + رمزگذاری با age برای Project S + [age](https://github.com/filosottile/age) یک ابزار رمزگذاری نامتقارن مدرن و امن است. با فعال‌سازی، فایل zip با کلید عمومی این پروژه رمزگذاری می‌شود تا بتوان آن را به‌صورت عمومی، مثلاً در GitHub issues، منتشر کرد. فراداده پیکربندی محلی diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index bdf0ee5..50ffd08 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -543,6 +543,12 @@ Удалить Поделиться Поделиться с конфигурацией + С конфигурацией + С журналом + Журналы и файлы конфигурации могут содержать личные данные и не должны публиковаться. + Файлы конфигурации могут содержать личные данные и не должны публиковаться. + Зашифровать с помощью age для Project S + [age](https://github.com/filosottile/age) — современный и безопасный инструмент асимметричного шифрования. При включении ZIP-файл шифруется открытым ключом этого проекта, поэтому его можно публиковать в открытом доступе, например в GitHub issues. Метаданные Конфигурация Локальный diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 80126bf..8e4cc46 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -534,6 +534,12 @@ 删除 分享 附带配置分享 + 附带配置 + 附带日志 + 日志和配置文件可能包含隐私内容,不应该被公开发布。 + 配置文件可能包含隐私内容,不应该被公开发布。 + 使用 age 加密给 Project S + [age](https://github.com/filosottile/age) 是一个现代且安全的非对称加密工具。启用后,zip 文件将通过本项目的公钥加密,以便在 GitHub issues 等公共场合发布。 元数据 配置 本地 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 242913b..10357ea 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -537,6 +537,12 @@ 刪除 分享 附帶配置分享 + 附帶配置 + 附帶日誌 + 日誌和配置檔案可能包含隱私內容,不應該被公開發佈。 + 配置檔案可能包含隱私內容,不應該被公開發佈。 + 使用 age 加密給 Project S + [age](https://github.com/filosottile/age) 是一個現代且安全的非對稱加密工具。啟用後,zip 檔案將透過本專案的公鑰加密,以便在 GitHub issues 等公開場合發佈。 元數據 配置 本地 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3afe185..75afc46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -543,6 +543,12 @@ Delete Share Share With Configuration + With Configuration + With Log + Logs and configuration files may contain private content and should not be made public. + Configuration files may contain private content and should not be made public. + Encrypt with age for Project S + [age](https://github.com/filosottile/age) is a modern and secure asymmetric encryption tool. When enabled, the zip file is encrypted with this project\'s public key so it can be posted publicly, e.g. in GitHub issues. Metadata Configuration Local