feat(settings): add troubleshooting page and app preferences import/export tool (#672)

This commit is contained in:
Ash 2024-03-28 16:00:36 +08:00 committed by GitHub
parent d749107bea
commit 826819a10b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 343 additions and 35 deletions

View File

@ -94,12 +94,5 @@ class AccountService @Inject constructor(
rssService.get().cancelSync()
context.dataStore.put(DataStoreKeys.CurrentAccountId, account.id!!)
context.dataStore.put(DataStoreKeys.CurrentAccountType, account.type.id)
// Restart
// context.packageManager.getLaunchIntentForPackage(context.packageName)?.let {
// it.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
// context.startActivity(it)
// android.os.Process.killProcess(android.os.Process.myPid())
// }
}
}

View File

@ -152,7 +152,7 @@ fun CrashReportPage(
val startIndex = msg.indexOf(hyperLinkText)
val endIndex = startIndex + hyperLinkText.length
addUrlAnnotation(
UrlAnnotation("https://github.com/Ashinch/ReadYou/issues/new?assignees=&labels=bug&projects=&template=bug_report.md&title="),
UrlAnnotation(stringResource(R.string.issue_tracer_url)),
start = startIndex,
end = endIndex
)
@ -226,4 +226,4 @@ fun CrashReportPage(
}
}
}
}

View File

@ -183,3 +183,9 @@ fun Context.getCustomTabsPackages(): List<String> {
return@mapNotNull null
}.toList()
}
fun Context.getPreferencesFile(): File =
File(filesDir.absolutePath + File.separator +
"datastore" + File.separator +
"settings.preferences_pb"
)

View File

@ -27,9 +27,9 @@ val Context.skipVersionNumber: String
val Context.isFirstLaunch: Boolean
get() = this.dataStore.get(DataStoreKeys.IsFirstLaunch) ?: true
val Context.currentAccountId: Int
get() = this.dataStore.get(DataStoreKeys.CurrentAccountId)!!
get() = this.dataStore.get(DataStoreKeys.CurrentAccountId) ?: 1
val Context.currentAccountType: Int
get() = this.dataStore.get(DataStoreKeys.CurrentAccountType)!!
get() = this.dataStore.get(DataStoreKeys.CurrentAccountType) ?: 1
val Context.initialPage: Int
get() = this.dataStore.get(DataStoreKeys.InitialPage) ?: 0

View File

@ -35,4 +35,8 @@ fun File.mkDir() {
val newF = File("${dirArray[0]}$pathTemp")
if (!newF.exists()) newF.mkdir()
}
}
}
fun ByteArray.isProbableProtobuf(): Boolean =
if (size < 2) false
else get(0) == 0x0a.toByte() && get(1) == 0x16.toByte()

View File

@ -8,7 +8,7 @@ import java.security.MessageDigest
object MimeType {
const val ANY = "*/*"
const val FONT = "font/ttf" // Not supported yet
const val FONT = "font/ttf"
const val OPML = "text/x-opml" // Not supported yet
const val JSON = "application/json"
}

View File

@ -52,6 +52,7 @@ import me.ash.reader.ui.page.settings.interaction.InteractionPage
import me.ash.reader.ui.page.settings.languages.LanguagesPage
import me.ash.reader.ui.page.settings.tips.LicenseListPage
import me.ash.reader.ui.page.settings.tips.TipsAndSupportPage
import me.ash.reader.ui.page.settings.troubleshooting.TroubleshootingPage
import me.ash.reader.ui.page.startup.StartupPage
import me.ash.reader.ui.theme.AppTheme
@ -237,6 +238,11 @@ fun HomeEntry(
LanguagesPage(navController = navController)
}
// Troubleshooting
forwardAndBackwardComposable(route = RouteName.TROUBLESHOOTING) {
TroubleshootingPage(navController = navController)
}
// Tips & Support
forwardAndBackwardComposable(route = RouteName.TIPS_AND_SUPPORT) {
TipsAndSupportPage(navController)

View File

@ -36,6 +36,9 @@ object RouteName {
// Languages
const val LANGUAGES = "languages"
// Troubleshooting
const val TROUBLESHOOTING = "troubleshooting"
// Tips & Support
const val TIPS_AND_SUPPORT = "tips_and_support"
const val LICENSE_LIST = "license_list"

View File

@ -35,8 +35,10 @@ import me.ash.reader.ui.component.RenameDialog
import me.ash.reader.ui.component.base.ClipboardTextField
import me.ash.reader.ui.component.base.RYDialog
import me.ash.reader.ui.component.base.TextFieldDialog
import me.ash.reader.ui.ext.MimeType
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.roundClick
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.page.home.feeds.FeedOptionView
@OptIn(
@ -51,12 +53,12 @@ fun SubscribeDialog(
val focusManager = LocalFocusManager.current
val subscribeUiState = subscribeViewModel.subscribeUiState.collectAsStateValue()
val groupsState = subscribeUiState.groups.collectAsState(initial = emptyList())
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
it?.let { uri ->
context.contentResolver.openInputStream(uri)?.let { inputStream ->
subscribeViewModel.importFromInputStream(inputStream)
}
}
} ?: context.showToast("Cannot open Input Stream with content resolver")
} ?: context.showToast("Cannot get activity result with launcher")
}
LaunchedEffect(subscribeUiState.visible) {
@ -180,7 +182,7 @@ fun SubscribeDialog(
TextButton(
onClick = {
focusManager.clearFocus()
launcher.launch("*/*")
launcher.launch(arrayOf(MimeType.ANY))
subscribeViewModel.hideDrawer()
}
) {

View File

@ -1,6 +1,5 @@
package me.ash.reader.ui.page.home.feeds.subscribe
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rometools.rome.feed.synd.SyndFeed
@ -59,12 +58,8 @@ class SubscribeViewModel @Inject constructor(
fun importFromInputStream(inputStream: InputStream) {
applicationScope.launch {
try {
opmlService.saveToDatabase(inputStream)
rssService.get().doSync()
} catch (e: Exception) {
Log.e("FeedsViewModel", "importFromInputStream: ", e)
}
opmlService.saveToDatabase(inputStream)
rssService.get().doSync()
}
}

View File

@ -1,10 +1,21 @@
package me.ash.reader.ui.page.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Lightbulb
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.TipsAndUpdates
import androidx.compose.material.icons.outlined.TouchApp
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -17,7 +28,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.core.os.LocaleListCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import me.ash.reader.R
@ -134,6 +144,17 @@ fun SettingsPage(
}
}
}
item {
SelectableSettingGroupItem(
title = stringResource(R.string.troubleshooting),
desc = stringResource(R.string.troubleshooting_desc),
icon = Icons.Outlined.BugReport,
) {
navController.navigate(RouteName.TROUBLESHOOTING) {
launchSingleTop = true
}
}
}
item {
SelectableSettingGroupItem(
title = stringResource(R.string.tips_and_support),

View File

@ -19,9 +19,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.PersonOff
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -61,6 +61,7 @@ import me.ash.reader.ui.component.base.Subtitle
import me.ash.reader.ui.component.base.TextFieldDialog
import me.ash.reader.ui.component.base.Tips
import me.ash.reader.ui.ext.DateFormat
import me.ash.reader.ui.ext.MimeType
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.getCurrentVersion
import me.ash.reader.ui.ext.showToast
@ -103,14 +104,14 @@ fun AccountDetailsPage(
}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("*/*")
ActivityResultContracts.CreateDocument(MimeType.ANY)
) { result ->
viewModel.exportAsOPML(selectedAccount!!.id!!) { string ->
result?.let { uri ->
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(string.toByteArray())
}
}
} ?: context.showToast("Cannot open Input Stream with content resolver")
} ?: context.showToast("Cannot get activity result with launcher")
}
}
@ -483,7 +484,7 @@ fun AccountDetailsPage(
TextButton(
onClick = {
exportOPMLModeDialogVisible = false
launcherOPMLFile(context, launcher)
subscriptionOPMLFileLauncher(context, launcher)
}
) {
Text(stringResource(R.string.export))
@ -502,11 +503,11 @@ fun AccountDetailsPage(
)
}
private fun launcherOPMLFile(
private fun subscriptionOPMLFileLauncher(
context: Context,
launcher: ManagedActivityResultLauncher<String, Uri?>,
) {
launcher.launch("Read-You-" +
"${context.getCurrentVersion()}-export-" +
"${context.getCurrentVersion()}-subscription-" +
"${Date().toString(DateFormat.YYYY_MM_DD_DASH_HH_MM_SS_DASH)}.opml")
}

View File

@ -0,0 +1,188 @@
package me.ash.reader.ui.page.settings.troubleshooting
import android.content.Context
import android.net.Uri
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.ReportGmailerrorred
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import me.ash.reader.R
import me.ash.reader.infrastructure.preference.OpenLinkPreference
import me.ash.reader.ui.component.base.Banner
import me.ash.reader.ui.component.base.DisplayText
import me.ash.reader.ui.component.base.FeedbackIconButton
import me.ash.reader.ui.component.base.RYDialog
import me.ash.reader.ui.component.base.RYScaffold
import me.ash.reader.ui.component.base.Subtitle
import me.ash.reader.ui.ext.DateFormat
import me.ash.reader.ui.ext.MimeType
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.getCurrentVersion
import me.ash.reader.ui.ext.openURL
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.ext.toString
import me.ash.reader.ui.page.settings.SettingItem
import me.ash.reader.ui.theme.palette.onLight
import java.util.Date
@Composable
fun TroubleshootingPage(
navController: NavHostController,
viewModel: TroubleshootingViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val uiState = viewModel.troubleshootingUiState.collectAsStateValue()
var byteArray by remember { mutableStateOf(ByteArray(0)) }
val exportLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(MimeType.ANY)
) { result ->
viewModel.exportPreferencesAsJSON(context) { byteArray ->
result?.let { uri ->
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(byteArray)
} ?: context.showToast("Cannot open Input Stream with content resolver")
} ?: context.showToast("Cannot get activity result with launcher")
}
}
val importLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) {
it?.let { uri ->
context.contentResolver.openInputStream(uri)?.use { inputStream ->
byteArray = inputStream.readBytes()
viewModel.tryImport(context, byteArray)
} ?: context.showToast("Cannot open Input Stream with content resolver")
} ?: context.showToast("Cannot get activity result with launcher")
}
RYScaffold(
containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface,
navigationIcon = {
FeedbackIconButton(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
tint = MaterialTheme.colorScheme.onSurface
) {
navController.popBackStack()
}
},
content = {
LazyColumn {
item {
DisplayText(text = stringResource(R.string.troubleshooting), desc = "")
Spacer(modifier = Modifier.height(16.dp))
Banner(
title = stringResource(R.string.bug_report),
icon = Icons.Outlined.Info,
action = {
Icon(
imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight,
contentDescription = stringResource(R.string.go_to),
)
},
) {
context.openURL(
context.getString(R.string.issue_tracer_url),
OpenLinkPreference.AutoPreferCustomTabs
)
}
Spacer(modifier = Modifier.height(16.dp))
}
item {
Subtitle(
modifier = Modifier.padding(horizontal = 24.dp),
text = stringResource(R.string.app_preferences),
)
SettingItem(
title = stringResource(R.string.import_from_protobuf_file),
onClick = {
importLauncher.launch(arrayOf(MimeType.ANY))
},
) {}
SettingItem(
title = stringResource(R.string.export_as_protobuf_file),
onClick = {
preferenceFileLauncher(context, exportLauncher)
},
) {}
Spacer(modifier = Modifier.height(24.dp))
}
item {
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
}
)
RYDialog(
visible = uiState.warningDialogVisible,
onDismissRequest = { viewModel.hideWarningDialog() },
icon = {
Icon(
imageVector = Icons.Outlined.ReportGmailerrorred,
contentDescription = stringResource(R.string.import_from_protobuf_file),
)
},
title = {
Text(text = stringResource(R.string.import_from_protobuf_file))
},
text = {
Text(text = stringResource(R.string.invalid_protobuf_file_warning))
},
confirmButton = {
TextButton(
onClick = {
viewModel.hideWarningDialog()
viewModel.importPreferencesFromJSON(context, byteArray)
}
) {
Text(text = stringResource(R.string.confirm))
}
},
dismissButton = {
TextButton(onClick = { viewModel.hideWarningDialog() }) {
Text(text = stringResource(R.string.cancel))
}
},
)
}
private fun preferenceFileLauncher(
context: Context,
launcher: ManagedActivityResultLauncher<String, Uri?>,
) {
launcher.launch("Read-You-" +
"${context.getCurrentVersion()}-settings-" +
"${Date().toString(DateFormat.YYYY_MM_DD_DASH_HH_MM_SS_DASH)}.preferences_pb")
}

View File

@ -0,0 +1,81 @@
package me.ash.reader.ui.page.settings.troubleshooting
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.ash.reader.domain.service.AccountService
import me.ash.reader.domain.service.OpmlService
import me.ash.reader.domain.service.RssService
import me.ash.reader.infrastructure.di.ApplicationScope
import me.ash.reader.infrastructure.di.DefaultDispatcher
import me.ash.reader.infrastructure.di.IODispatcher
import me.ash.reader.infrastructure.di.MainDispatcher
import me.ash.reader.ui.ext.getPreferencesFile
import me.ash.reader.ui.ext.isProbableProtobuf
import me.ash.reader.ui.ext.restart
import javax.inject.Inject
@HiltViewModel
class TroubleshootingViewModel @Inject constructor(
private val accountService: AccountService,
private val rssService: RssService,
private val opmlService: OpmlService,
@IODispatcher
private val ioDispatcher: CoroutineDispatcher,
@DefaultDispatcher
private val defaultDispatcher: CoroutineDispatcher,
@MainDispatcher
private val mainDispatcher: CoroutineDispatcher,
@ApplicationScope
private val applicationScope: CoroutineScope,
) : ViewModel() {
private val _troubleshootingUiState = MutableStateFlow(TroubleshootingUiState())
val troubleshootingUiState: StateFlow<TroubleshootingUiState> =
_troubleshootingUiState.asStateFlow()
fun showWarningDialog() {
_troubleshootingUiState.update { it.copy(warningDialogVisible = true) }
}
fun hideWarningDialog() {
_troubleshootingUiState.update { it.copy(warningDialogVisible = false) }
}
fun tryImport(context: Context, byteArray: ByteArray) {
if (!byteArray.isProbableProtobuf()) {
showWarningDialog()
} else {
importPreferencesFromJSON(context, byteArray)
}
}
fun importPreferencesFromJSON(context: Context, byteArray: ByteArray) {
viewModelScope.launch(ioDispatcher) {
val file = context.getPreferencesFile()
if (file.exists()) file.delete()
if (file.createNewFile()) file.writeBytes(byteArray)
context.restart()
}
}
fun exportPreferencesAsJSON(context: Context, callback: (ByteArray) -> Unit = {}) {
viewModelScope.launch(ioDispatcher) {
val file = context.getPreferencesFile()
callback(if (file.exists()) file.readBytes() else byteArrayOf())
}
}
}
data class TroubleshootingUiState(
val isLoading: Boolean = false,
val warningDialogVisible: Boolean = false,
)

View File

@ -410,7 +410,7 @@
<string name="grey_out_articles">Grey out articles</string>
<string name="all_read">All read</string>
<string name="read_excluding_starred">Read, excluding starred</string>
<string name="external_links">External links</string>
<string name="external_links">External Links</string>
<string name="unexpected_error_title">Oops! Something went wrong…</string>
<string name="copy_error_report">Copy error report</string>
<string name="submit_bug_report">submit a bug report on GitHub</string>
@ -432,4 +432,12 @@
<string name="shared_content">Shared content</string>
<string name="only_link">Only link</string>
<string name="title_and_link">Title and link</string>
<string name="troubleshooting">Troubleshooting</string>
<string name="troubleshooting_desc">Bug report, debug tools</string>
<string name="bug_report">Bug report</string>
<string name="issue_tracer_url" translatable="false">https://github.com/Ashinch/ReadYou/issues</string>
<string name="app_preferences">App preferences</string>
<string name="import_from_protobuf_file">Import from protobuf file</string>
<string name="export_as_protobuf_file">Export as protobuf file</string>
<string name="invalid_protobuf_file_warning">This file may not be a valid protobuf file. Importing it could potentially corrupt the app and result in the loss of current preferences. Are you sure you want to proceed?</string>
</resources>