mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-07-09 04:26:57 +00:00
Rewrite DebugInfoActivity to M3 (#744)
* Convert M2 calls to M3 * Extract composable to screen * Extract viewmodel * Make screen model independent * Use only primitive types in screen * Introduce uiState class and switch to compose state where easy * Switch remaining live data to compose state * Add kdoc * Add scrolling, adapt buttons to M3 * Move Intent logic to Activity - create/handle Intent in Activity (may be replaced by NavGraph in future) - Activity: pass unpacked initial data to Screen - Screen: use hiltViewModel (adds hilt-navigation-compose dependency) to create model with initial data - Screen: use Column instead of LazyColumn * Fix test * Optimize imports * Minor changes * Move AppTheme, fix showDebugInfo * View instead of share logs; make local/remote resource smaller; make remote resource selectable * Leave space for scrolling down past the FAB; don't show "Local resource: null" * Re-order composables --------- Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
parent
b5334887e8
commit
86252f9117
|
@ -22,7 +22,6 @@ import at.bitfire.dav4jvm.property.push.Topic
|
|||
import at.bitfire.dav4jvm.property.webdav.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
|
|
@ -15,7 +15,6 @@ import at.bitfire.davdroid.Constants
|
|||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
||||
|
|
|
@ -12,7 +12,6 @@ import at.bitfire.davdroid.db.Collection
|
|||
import at.bitfire.davdroid.db.Principal
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.JtxCollectionFactory
|
||||
|
|
|
@ -13,7 +13,6 @@ import at.bitfire.davdroid.Constants
|
|||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.DmfsTaskListFactory
|
||||
|
|
|
@ -26,7 +26,6 @@ import at.bitfire.davdroid.resource.LocalCalendar
|
|||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
|
|
|
@ -24,7 +24,6 @@ import at.bitfire.davdroid.resource.LocalJtxCollection
|
|||
import at.bitfire.davdroid.resource.LocalJtxICalObject
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.JtxICalObject
|
||||
|
|
|
@ -846,7 +846,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
|||
.withCause(e)
|
||||
.withLocalResource(
|
||||
try {
|
||||
local.toString()
|
||||
local?.toString()
|
||||
} catch (e: OutOfMemoryError) {
|
||||
// for instance because of a huge contact photo; maybe we're lucky and can fetch it
|
||||
null
|
||||
|
|
|
@ -24,7 +24,6 @@ import at.bitfire.davdroid.resource.LocalResource
|
|||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.davdroid.util.lastSegment
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.Task
|
||||
|
|
|
@ -3,116 +3,22 @@
|
|||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Application
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.LocaleList
|
||||
import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.FloatingActionButton
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LinearProgressIndicator
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.SnackbarDuration
|
||||
import androidx.compose.material.SnackbarHost
|
||||
import androidx.compose.material.SnackbarHostState
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Adb
|
||||
import androidx.compose.material.icons.rounded.BugReport
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
|
||||
import at.bitfire.davdroid.ui.composable.BasicTopAppBar
|
||||
import at.bitfire.davdroid.ui.composable.CardWithImage
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName
|
||||
import at.techbee.jtx.JtxContract
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.io.ByteOrderMark
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.File
|
||||
import java.io.IOError
|
||||
import java.io.IOException
|
||||
import java.io.StringReader
|
||||
import java.io.Writer
|
||||
import java.util.TimeZone
|
||||
import java.util.logging.Level
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.inject.Inject
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter as asCalendarSyncAdapter
|
||||
import at.bitfire.vcard4android.Utils.asSyncAdapter as asContactsSyncAdapter
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter as asJtxSyncAdapter
|
||||
|
||||
/**
|
||||
* Debug info activity. Provides verbose information for debugging and support. Should enable users
|
||||
|
@ -145,234 +51,39 @@ class DebugInfoActivity : AppCompatActivity() {
|
|||
|
||||
/** URL of remote resource related to the problem (plain-text [String]) */
|
||||
private const val EXTRA_REMOTE_RESOURCE = "remoteResource"
|
||||
|
||||
const val FILE_DEBUG_INFO = "debug-info.txt"
|
||||
const val FILE_LOGS = "logs.txt"
|
||||
}
|
||||
|
||||
@Inject lateinit var modelFactory: ReportModel.Factory
|
||||
private val model by viewModels<ReportModel> {
|
||||
object: ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
modelFactory.create(intent.extras) as T
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
model.zipFile.observe(this) { zipFile ->
|
||||
if (zipFile == null) return@observe
|
||||
|
||||
// ZIP file is ready
|
||||
shareFile(
|
||||
zipFile,
|
||||
subject = "${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info",
|
||||
text = getString(R.string.debug_info_attached),
|
||||
type = "*/*", // application/zip won't show all apps that can manage binary files, like ShareViaHttp
|
||||
)
|
||||
|
||||
// only share ZIP file once
|
||||
model.zipFile.value = null
|
||||
}
|
||||
val extras = intent.extras
|
||||
|
||||
setContent {
|
||||
M2Theme {
|
||||
val debugInfo by model.debugInfo.observeAsState()
|
||||
val zipProgress by model.zipProgress.observeAsState(false)
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
if (debugInfo != null && !zipProgress) {
|
||||
FloatingActionButton(
|
||||
onClick = model::generateZip
|
||||
) {
|
||||
Icon(Icons.Rounded.Share, stringResource(R.string.share))
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
},
|
||||
topBar = {
|
||||
BasicTopAppBar(
|
||||
titleStringRes = R.string.debug_info_title
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
val error by model.error.observeAsState()
|
||||
LaunchedEffect(error) {
|
||||
error?.let {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = it,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
|
||||
model.error.value = null
|
||||
}
|
||||
}
|
||||
|
||||
Content(paddingValues)
|
||||
}
|
||||
}
|
||||
DebugInfoScreen(
|
||||
account = extras?.getParcelable(EXTRA_ACCOUNT),
|
||||
authority = extras?.getString(EXTRA_AUTHORITY),
|
||||
cause = extras?.getSerializable(EXTRA_CAUSE) as? Throwable,
|
||||
localResource = extras?.getString(EXTRA_LOCAL_RESOURCE),
|
||||
remoteResource = extras?.getString(EXTRA_REMOTE_RESOURCE),
|
||||
logs = extras?.getString(EXTRA_LOGS),
|
||||
onShareZipFile = ::shareZipFile,
|
||||
onViewFile = ::viewFile,
|
||||
onNavUp = ::onSupportNavigateUp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Content(paddingValues: PaddingValues) {
|
||||
val debugInfo by model.debugInfo.observeAsState()
|
||||
val zipProgress by model.zipProgress.observeAsState(false)
|
||||
val modelCause by model.cause.observeAsState()
|
||||
val localResource by model.localResource.observeAsState()
|
||||
val remoteResource by model.remoteResource.observeAsState()
|
||||
val logFile by model.logFile.observeAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
if (debugInfo == null)
|
||||
item {
|
||||
LinearProgressIndicator(color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
|
||||
if (debugInfo != null) {
|
||||
if (zipProgress)
|
||||
item {
|
||||
LinearProgressIndicator(color = MaterialTheme.colors.secondary)
|
||||
}
|
||||
|
||||
item {
|
||||
CardWithImage(
|
||||
image = painterResource(R.drawable.undraw_server_down),
|
||||
imageAlignment = BiasAlignment(0f, .7f),
|
||||
title = stringResource(R.string.debug_info_archive_caption),
|
||||
subtitle = stringResource(R.string.debug_info_archive_subtitle),
|
||||
message = stringResource(R.string.debug_info_archive_text),
|
||||
icon = Icons.Rounded.Share,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = model::generateZip,
|
||||
enabled = !zipProgress
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.debug_info_archive_share).uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
modelCause?.let { cause ->
|
||||
item {
|
||||
CardWithImage(
|
||||
title = when (cause) {
|
||||
is HttpException -> stringResource(if (cause.code / 100 == 5) R.string.debug_info_server_error else R.string.debug_info_http_error)
|
||||
is DavException -> stringResource(R.string.debug_info_webdav_error)
|
||||
is IOException, is IOError -> stringResource(R.string.debug_info_io_error)
|
||||
else -> cause::class.java.simpleName
|
||||
},
|
||||
subtitle = cause.localizedMessage,
|
||||
message = stringResource(
|
||||
if (cause is HttpException)
|
||||
when {
|
||||
cause.code == 403 -> R.string.debug_info_http_403_description
|
||||
cause.code == 404 -> R.string.debug_info_http_404_description
|
||||
cause.code / 100 == 5 -> R.string.debug_info_http_5xx_description
|
||||
else -> R.string.debug_info_unexpected_error
|
||||
}
|
||||
else
|
||||
R.string.debug_info_unexpected_error
|
||||
),
|
||||
icon = Icons.Rounded.Info,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
TextButton(
|
||||
enabled = debugInfo != null,
|
||||
onClick = { viewFile(debugInfo!!) }
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.debug_info_view_details).uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
debugInfo?.let { info ->
|
||||
item {
|
||||
CardWithImage(
|
||||
title = stringResource(R.string.debug_info_title),
|
||||
subtitle = stringResource(R.string.debug_info_subtitle),
|
||||
icon = Icons.Rounded.BugReport,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { viewFile(info) }
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.debug_info_view_details).uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (localResource != null || remoteResource != null) item {
|
||||
CardWithImage(
|
||||
title = stringResource(R.string.debug_info_involved_caption),
|
||||
subtitle = stringResource(R.string.debug_info_involved_subtitle),
|
||||
icon = Icons.Rounded.Adb,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
remoteResource?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_info_involved_remote),
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
Text(
|
||||
text = it,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
localResource?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_info_involved_local),
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
Text(
|
||||
text = it,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
logFile?.let { logs ->
|
||||
item {
|
||||
CardWithImage(
|
||||
title = stringResource(R.string.debug_info_logs_caption),
|
||||
subtitle = stringResource(R.string.debug_info_logs_subtitle),
|
||||
icon = Icons.Rounded.BugReport,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { shareFile(logs) }
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.debug_info_logs_view).uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun shareZipFile(file: File) {
|
||||
shareFile(
|
||||
file,
|
||||
"${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info",
|
||||
getString(R.string.debug_info_attached),
|
||||
"*/*", // application/zip won't show all apps that can manage binary files, like ShareViaHttp
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an activity passing sharing intent along
|
||||
*/
|
||||
private fun shareFile(
|
||||
file: File,
|
||||
subject: String? = null,
|
||||
|
@ -392,6 +103,9 @@ class DebugInfoActivity : AppCompatActivity() {
|
|||
.startChooser()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts an activity passing file viewer intent along
|
||||
*/
|
||||
private fun viewFile(
|
||||
file: File,
|
||||
title: String? = null
|
||||
|
@ -409,521 +123,9 @@ class DebugInfoActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
|
||||
class ReportModel @AssistedInject constructor (
|
||||
val context: Application,
|
||||
@Assisted extras: Bundle?
|
||||
) : AndroidViewModel(context) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(extras: Bundle?): ReportModel
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
@Inject
|
||||
lateinit var settings: SettingsManager
|
||||
|
||||
val cause = MutableLiveData<Throwable>()
|
||||
var logFile = MutableLiveData<File>()
|
||||
val localResource = MutableLiveData<String>()
|
||||
val remoteResource = MutableLiveData<String>()
|
||||
val debugInfo = MutableLiveData<File>()
|
||||
|
||||
// feedback for UI
|
||||
val zipProgress = MutableLiveData(false)
|
||||
val zipFile = MutableLiveData<File>()
|
||||
val error = MutableLiveData<String>()
|
||||
|
||||
init {
|
||||
// create debug info directory
|
||||
val debugDir = Logger.debugDir() ?: throw IOException("Couldn't create debug info directory")
|
||||
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
// create log file from EXTRA_LOGS or log file
|
||||
val logsText = extras?.getString(EXTRA_LOGS)
|
||||
if (logsText != null) {
|
||||
val file = File(debugDir, FILE_LOGS)
|
||||
if (!file.exists() || file.canWrite()) {
|
||||
file.writer().buffered().use { writer ->
|
||||
IOUtils.copy(StringReader(logsText), writer)
|
||||
}
|
||||
logFile.postValue(file)
|
||||
} else
|
||||
Logger.log.warning("Can't write logs to $file")
|
||||
} else Logger.getDebugLogFile()?.let { debugLogFile ->
|
||||
if (debugLogFile.isFile && debugLogFile.canRead())
|
||||
logFile.postValue(debugLogFile)
|
||||
}
|
||||
|
||||
val throwable = extras?.getSerializable(EXTRA_CAUSE) as? Throwable
|
||||
cause.postValue(throwable)
|
||||
|
||||
val local = extras?.getString(EXTRA_LOCAL_RESOURCE)
|
||||
localResource.postValue(local)
|
||||
|
||||
val remote = extras?.getString(EXTRA_REMOTE_RESOURCE)
|
||||
remoteResource.postValue(remote)
|
||||
|
||||
generateDebugInfo(
|
||||
extras?.getParcelable(EXTRA_ACCOUNT),
|
||||
extras?.getString(EXTRA_AUTHORITY),
|
||||
throwable,
|
||||
local,
|
||||
remote
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateDebugInfo(syncAccount: Account?, syncAuthority: String?, cause: Throwable?, localResource: String?, remoteResource: String?) {
|
||||
val debugInfoFile = File(Logger.debugDir(), FILE_DEBUG_INFO)
|
||||
debugInfoFile.writer().buffered().use { writer ->
|
||||
writer.append(ByteOrderMark.UTF_BOM)
|
||||
writer.append("--- BEGIN DEBUG INFO ---\n\n")
|
||||
|
||||
// begin with most specific information
|
||||
if (syncAccount != null || syncAuthority != null) {
|
||||
writer.append("SYNCHRONIZATION INFO\n")
|
||||
if (syncAccount != null)
|
||||
writer.append("Account: $syncAccount\n")
|
||||
if (syncAuthority != null)
|
||||
writer.append("Authority: $syncAuthority\n")
|
||||
writer.append("\n")
|
||||
}
|
||||
|
||||
cause?.let {
|
||||
// Log.getStackTraceString(e) returns "" in case of UnknownHostException
|
||||
writer.append("EXCEPTION\n${ExceptionUtils.getStackTrace(cause)}\n")
|
||||
}
|
||||
|
||||
// exception details
|
||||
if (cause is DavException) {
|
||||
cause.request?.let { request ->
|
||||
writer.append("HTTP REQUEST\n$request\n")
|
||||
cause.requestBody?.let { writer.append(it) }
|
||||
writer.append("\n\n")
|
||||
}
|
||||
cause.response?.let { response ->
|
||||
writer.append("HTTP RESPONSE\n$response\n")
|
||||
cause.responseBody?.let { writer.append(it) }
|
||||
writer.append("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (localResource != null)
|
||||
writer.append("LOCAL RESOURCE\n$localResource\n\n")
|
||||
|
||||
if (remoteResource != null)
|
||||
writer.append("REMOTE RESOURCE\n$remoteResource\n\n")
|
||||
|
||||
// software info
|
||||
try {
|
||||
writer.append("SOFTWARE INFORMATION\n")
|
||||
val table = TextTable("Package", "Version", "Code", "Installer", "Notes")
|
||||
val pm = context.packageManager
|
||||
|
||||
val packageNames = mutableSetOf( // we always want info about these packages:
|
||||
BuildConfig.APPLICATION_ID, // DAVx5
|
||||
ProviderName.JtxBoard.packageName, // jtx Board
|
||||
ProviderName.OpenTasks.packageName, // OpenTasks
|
||||
ProviderName.TasksOrg.packageName // tasks.org
|
||||
)
|
||||
// ... and info about contact and calendar provider
|
||||
for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY))
|
||||
pm.resolveContentProvider(authority, 0)?.let { packageNames += it.packageName }
|
||||
// ... and info about contact, calendar, task-editing apps
|
||||
val dataUris = arrayOf(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
TaskContract.Tasks.getContentUri(ProviderName.OpenTasks.authority),
|
||||
TaskContract.Tasks.getContentUri(ProviderName.TasksOrg.authority)
|
||||
)
|
||||
for (uri in dataUris) {
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, /* some random ID */ 1))
|
||||
for (info in pm.queryIntentActivities(viewIntent, 0))
|
||||
packageNames += info.activityInfo.packageName
|
||||
}
|
||||
|
||||
for (packageName in packageNames)
|
||||
try {
|
||||
val info = pm.getPackageInfo(packageName, 0)
|
||||
val appInfo = info.applicationInfo
|
||||
val notes = mutableListOf<String>()
|
||||
if (!appInfo.enabled)
|
||||
notes += "disabled"
|
||||
if (appInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
|
||||
notes += "<em>on external storage</em>"
|
||||
table.addLine(
|
||||
info.packageName, info.versionName, PackageInfoCompat.getLongVersionCode(info),
|
||||
pm.getInstallerPackageName(info.packageName) ?: '—', notes.joinToString(", ")
|
||||
)
|
||||
} catch (ignored: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
writer.append(table.toString())
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
|
||||
}
|
||||
|
||||
// system info
|
||||
val locales: Any = LocaleList.getAdjustedDefault()
|
||||
writer.append(
|
||||
"\nSYSTEM INFORMATION\n\n" +
|
||||
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
|
||||
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" +
|
||||
"Locale(s): $locales\n" +
|
||||
"Time zone: ${TimeZone.getDefault().id}\n"
|
||||
)
|
||||
val filesPath = Environment.getDataDirectory()
|
||||
val statFs = StatFs(filesPath.path)
|
||||
writer.append("Internal memory ($filesPath): ")
|
||||
.append(FileUtils.byteCountToDisplaySize(statFs.availableBytes))
|
||||
.append(" free of ")
|
||||
.append(FileUtils.byteCountToDisplaySize(statFs.totalBytes))
|
||||
.append("\n\n")
|
||||
|
||||
// power saving
|
||||
if (Build.VERSION.SDK_INT >= 28)
|
||||
context.getSystemService<UsageStatsManager>()?.let { statsManager ->
|
||||
val bucket = statsManager.appStandbyBucket
|
||||
writer
|
||||
.append("App standby bucket: ")
|
||||
.append(
|
||||
when {
|
||||
bucket <= 5 -> "exempted (very good)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_ACTIVE -> "active (good)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> "working set (bad: job restrictions apply)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_FREQUENT -> "frequent (bad: job restrictions apply)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_RARE -> "rare (very bad: job and network restrictions apply)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_RESTRICTED -> "restricted (very bad: job and network restrictions apply)"
|
||||
else -> "$bucket"
|
||||
}
|
||||
)
|
||||
writer.append('\n')
|
||||
}
|
||||
context.getSystemService<PowerManager>()?.let { powerManager ->
|
||||
writer.append("App exempted from power saving: ")
|
||||
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes (good)" else "no (bad)")
|
||||
.append('\n')
|
||||
.append("System in power-save mode: ")
|
||||
.append(if (powerManager.isPowerSaveMode) "yes (restrictions apply!)" else "no")
|
||||
.append('\n')
|
||||
}
|
||||
// system-wide sync
|
||||
writer.append("System-wide synchronization: ")
|
||||
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
|
||||
.append("\n\n")
|
||||
|
||||
// connectivity
|
||||
context.getSystemService<ConnectivityManager>()?.let { connectivityManager ->
|
||||
writer.append("\nCONNECTIVITY\n\n")
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
connectivityManager.allNetworks.sortedByDescending { it == activeNetwork }.forEach { network ->
|
||||
val properties = connectivityManager.getLinkProperties(network)
|
||||
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
|
||||
writer.append(if (network == activeNetwork) " ☒ " else " ☐ ")
|
||||
.append(properties?.interfaceName ?: "?")
|
||||
.append("\n - ")
|
||||
.append(capabilities.toString().replace('&', ' '))
|
||||
.append('\n')
|
||||
}
|
||||
if (properties != null) {
|
||||
writer.append(" - DNS: ")
|
||||
.append(properties.dnsServers.joinToString(", ") { it.hostAddress })
|
||||
if (Build.VERSION.SDK_INT >= 28 && properties.isPrivateDnsActive)
|
||||
writer.append(" (private mode)")
|
||||
writer.append('\n')
|
||||
}
|
||||
}
|
||||
writer.append('\n')
|
||||
|
||||
connectivityManager.defaultProxy?.let { proxy ->
|
||||
writer.append("System default proxy: ${proxy.host}:${proxy.port}\n")
|
||||
}
|
||||
writer.append("Data saver: ").append(
|
||||
when (connectivityManager.restrictBackgroundStatus) {
|
||||
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> "enabled"
|
||||
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED -> "whitelisted"
|
||||
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> "disabled"
|
||||
else -> connectivityManager.restrictBackgroundStatus.toString()
|
||||
}
|
||||
).append('\n')
|
||||
writer.append('\n')
|
||||
}
|
||||
|
||||
writer.append("\nCONFIGURATION\n\n")
|
||||
// notifications
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
writer.append("\nNotifications")
|
||||
if (!nm.areNotificationsEnabled())
|
||||
writer.append(" (blocked!)")
|
||||
writer.append(":\n")
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val channelsWithoutGroup = nm.notificationChannels.toMutableSet()
|
||||
for (group in nm.notificationChannelGroups) {
|
||||
writer.append(" - ${group.id}")
|
||||
if (Build.VERSION.SDK_INT >= 28)
|
||||
writer.append(" isBlocked=${group.isBlocked}")
|
||||
writer.append('\n')
|
||||
for (channel in group.channels) {
|
||||
writer.append(" * ${channel.id}: importance=${channel.importance}\n")
|
||||
channelsWithoutGroup -= channel
|
||||
}
|
||||
}
|
||||
for (channel in channelsWithoutGroup)
|
||||
writer.append(" - ${channel.id}: importance=${channel.importance}\n")
|
||||
}
|
||||
writer.append('\n')
|
||||
// permissions
|
||||
writer.append("Permissions:\n")
|
||||
val ownPkgInfo = context.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS)
|
||||
for (permission in ownPkgInfo.requestedPermissions) {
|
||||
val shortPermission = permission.removePrefix("android.permission.")
|
||||
writer.append(" - $shortPermission: ")
|
||||
.append(
|
||||
if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED)
|
||||
"granted"
|
||||
else
|
||||
"denied"
|
||||
)
|
||||
.append('\n')
|
||||
}
|
||||
writer.append('\n')
|
||||
|
||||
// main accounts
|
||||
writer.append("\nACCOUNTS\n\n")
|
||||
val accountManager = AccountManager.get(context)
|
||||
val mainAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type))
|
||||
val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList()
|
||||
for (account in mainAccounts) {
|
||||
dumpMainAccount(account, writer)
|
||||
|
||||
val iter = addressBookAccounts.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val addressBookAccount = iter.next()
|
||||
val mainAccount = Account(
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME),
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
)
|
||||
if (mainAccount == account) {
|
||||
dumpAddressBookAccount(addressBookAccount, accountManager, writer)
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (addressBookAccounts.isNotEmpty()) {
|
||||
writer.append("Address book accounts without main account:\n")
|
||||
for (account in addressBookAccounts)
|
||||
dumpAddressBookAccount(account, accountManager, writer)
|
||||
}
|
||||
|
||||
// database dump
|
||||
writer.append("\nDATABASE DUMP\n\n")
|
||||
db.dump(writer, arrayOf("webdav_document"))
|
||||
|
||||
// app settings
|
||||
writer.append("\nAPP SETTINGS\n\n")
|
||||
settings.dump(writer)
|
||||
|
||||
writer.append("--- END DEBUG INFO ---\n")
|
||||
writer.toString()
|
||||
}
|
||||
debugInfo.postValue(debugInfoFile)
|
||||
}
|
||||
|
||||
fun generateZip() {
|
||||
try {
|
||||
zipProgress.postValue(true)
|
||||
|
||||
val file = File(Logger.debugDir(), "davx5-debug.zip")
|
||||
Logger.log.fine("Writing debug info to ${file.absolutePath}")
|
||||
ZipOutputStream(file.outputStream().buffered()).use { zip ->
|
||||
zip.setLevel(9)
|
||||
debugInfo.value?.let { debugInfo ->
|
||||
zip.putNextEntry(ZipEntry("debug-info.txt"))
|
||||
debugInfo.inputStream().use {
|
||||
IOUtils.copy(it, zip)
|
||||
}
|
||||
zip.closeEntry()
|
||||
}
|
||||
|
||||
val logs = logFile.value
|
||||
if (logs != null) {
|
||||
// verbose logs available
|
||||
zip.putNextEntry(ZipEntry(logs.name))
|
||||
logs.inputStream().use {
|
||||
IOUtils.copy(it, zip)
|
||||
}
|
||||
zip.closeEntry()
|
||||
} else {
|
||||
// logcat (short logs)
|
||||
try {
|
||||
Runtime.getRuntime().exec("logcat -d").also { logcat ->
|
||||
zip.putNextEntry(ZipEntry("logcat.txt"))
|
||||
IOUtils.copy(logcat.inputStream, zip)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't attach logcat", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// success, show ZIP file
|
||||
zipFile.postValue(file)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't generate debug info ZIP", e)
|
||||
error.postValue(e.localizedMessage)
|
||||
} finally {
|
||||
zipProgress.postValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun dumpMainAccount(account: Account, writer: Writer) {
|
||||
writer.append("\n\n - Account: ${account.name}\n")
|
||||
writer.append(dumpAccount(account, AccountDumpInfo.mainAccount(context, account)))
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
|
||||
val credentials = accountSettings.credentials()
|
||||
val authStr = mutableListOf<String>()
|
||||
if (credentials.username != null)
|
||||
authStr += "user name"
|
||||
if (credentials.password != null)
|
||||
authStr += "password"
|
||||
if (credentials.certificateAlias != null)
|
||||
authStr += "client certificate"
|
||||
credentials.authState?.let { authState ->
|
||||
authStr += "OAuth [${authState.authorizationServiceConfiguration?.authorizationEndpoint}]"
|
||||
}
|
||||
if (authStr.isNotEmpty())
|
||||
writer .append(" Authentication: ")
|
||||
.append(authStr.joinToString(", "))
|
||||
.append("\n")
|
||||
|
||||
writer.append(" WiFi only: ${accountSettings.getSyncWifiOnly()}")
|
||||
accountSettings.getSyncWifiOnlySSIDs()?.let { ssids ->
|
||||
writer.append(", SSIDs: ${ssids.joinToString(", ")}")
|
||||
}
|
||||
writer.append(
|
||||
"\n Contact group method: ${accountSettings.getGroupMethod()}\n" +
|
||||
" Time range (past days): ${accountSettings.getTimeRangePastDays()}\n" +
|
||||
" Default alarm (min before): ${accountSettings.getDefaultAlarm()}\n" +
|
||||
" Manage calendar colors: ${accountSettings.getManageCalendarColors()}\n" +
|
||||
" Use event colors: ${accountSettings.getEventColors()}\n"
|
||||
)
|
||||
|
||||
writer.append("\nSync workers:\n")
|
||||
.append(dumpSyncWorkersInfo(account))
|
||||
.append("\n")
|
||||
} catch (e: InvalidAccountException) {
|
||||
writer.append("$e\n")
|
||||
}
|
||||
writer.append('\n')
|
||||
}
|
||||
|
||||
private fun dumpAddressBookAccount(account: Account, accountManager: AccountManager, writer: Writer) {
|
||||
writer.append(" * Address book: ${account.name}\n")
|
||||
val table = dumpAccount(account, AccountDumpInfo.addressBookAccount(account))
|
||||
writer.append(TextTable.indent(table, 4))
|
||||
.append("URL: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_URL)}\n")
|
||||
.append(" Read-only: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_READ_ONLY) ?: 0}\n")
|
||||
}
|
||||
|
||||
private fun dumpAccount(account: Account, infos: Iterable<AccountDumpInfo>): String {
|
||||
val table = TextTable("Authority", "isSyncable", "syncAutomatically", "Interval", "Entries")
|
||||
for (info in infos) {
|
||||
var nrEntries = "—"
|
||||
if (info.countUri != null)
|
||||
try {
|
||||
context.contentResolver.acquireContentProviderClient(info.authority)?.use { client ->
|
||||
client.query(info.countUri, null, null, null, null)?.use { cursor ->
|
||||
nrEntries = "${cursor.count} ${info.countStr}"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
nrEntries = e.toString()
|
||||
}
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
table.addLine(
|
||||
info.authority,
|
||||
ContentResolver.getIsSyncable(account, info.authority),
|
||||
ContentResolver.getSyncAutomatically(account, info.authority), // content-triggered sync
|
||||
accountSettings.getSyncInterval(info.authority)?.let {"${it/60} min"},
|
||||
nrEntries
|
||||
)
|
||||
}
|
||||
return table.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets sync workers info
|
||||
* Note: WorkManager does not return worker names when queried, so we create them and ask
|
||||
* whether they exist one by one
|
||||
*/
|
||||
private fun dumpSyncWorkersInfo(account: Account): String {
|
||||
val table = TextTable("Tags", "Authority", "State", "Next run", "Retries", "Generation", "Periodicity")
|
||||
listOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority
|
||||
).forEach { authority ->
|
||||
val tag = BaseSyncWorker.commonTag(account, authority)
|
||||
WorkManager.getInstance(context).getWorkInfos(
|
||||
WorkQuery.Builder.fromTags(listOf(tag)).build()
|
||||
).get().forEach { workInfo ->
|
||||
table.addLine(
|
||||
workInfo.tags.map { it.replace("\\bat\\.bitfire\\.davdroid\\.".toRegex(), ".") },
|
||||
authority,
|
||||
"${workInfo.state} (${workInfo.stopReason})",
|
||||
workInfo.nextScheduleTimeMillis.let { nextRun ->
|
||||
when (nextRun) {
|
||||
Long.MAX_VALUE -> "—"
|
||||
else -> DateUtils.getRelativeTimeSpanString(nextRun)
|
||||
}
|
||||
},
|
||||
workInfo.runAttemptCount,
|
||||
workInfo.generation,
|
||||
workInfo.periodicityInfo?.let { periodicity ->
|
||||
"every ${periodicity.repeatIntervalMillis/60000} min"
|
||||
} ?: "not periodic"
|
||||
)
|
||||
}
|
||||
}
|
||||
return table.toString()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
data class AccountDumpInfo(
|
||||
val account: Account,
|
||||
val authority: String,
|
||||
val countUri: Uri?,
|
||||
val countStr: String?,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun mainAccount(context: Context, account: Account) = listOf(
|
||||
AccountDumpInfo(account, context.getString(R.string.address_books_authority), null, null),
|
||||
AccountDumpInfo(account, CalendarContract.AUTHORITY, CalendarContract.Events.CONTENT_URI.asCalendarSyncAdapter(account), "event(s)"),
|
||||
AccountDumpInfo(account, ProviderName.JtxBoard.authority, JtxContract.JtxICalObject.CONTENT_URI.asJtxSyncAdapter(account), "jtx Board ICalObject(s)"),
|
||||
AccountDumpInfo(account, ProviderName.OpenTasks.authority, TaskContract.Tasks.getContentUri(ProviderName.OpenTasks.authority).asCalendarSyncAdapter(account), "OpenTasks task(s)"),
|
||||
AccountDumpInfo(account, ProviderName.TasksOrg.authority, TaskContract.Tasks.getContentUri(ProviderName.TasksOrg.authority).asCalendarSyncAdapter(account), "tasks.org task(s)"),
|
||||
AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "wrongly assigned raw contact(s)")
|
||||
)
|
||||
|
||||
fun addressBookAccount(account: Account) = listOf(
|
||||
AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "raw contact(s)")
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Builder for [DebugInfoActivity] intents
|
||||
*/
|
||||
class IntentBuilder(context: Context) {
|
||||
|
||||
companion object {
|
||||
|
@ -957,13 +159,19 @@ class DebugInfoActivity : AppCompatActivity() {
|
|||
|
||||
fun withLocalResource(dump: String?): IntentBuilder {
|
||||
if (dump != null)
|
||||
intent.putExtra(EXTRA_LOCAL_RESOURCE, StringUtils.abbreviate(dump, MAX_ELEMENT_SIZE))
|
||||
intent.putExtra(
|
||||
EXTRA_LOCAL_RESOURCE,
|
||||
StringUtils.abbreviate(dump, MAX_ELEMENT_SIZE)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
fun withLogs(logs: String?): IntentBuilder {
|
||||
if (logs != null)
|
||||
intent.putExtra(EXTRA_LOGS, StringUtils.abbreviate(logs, MAX_ELEMENT_SIZE))
|
||||
intent.putExtra(
|
||||
EXTRA_LOGS,
|
||||
StringUtils.abbreviate(logs, MAX_ELEMENT_SIZE)
|
||||
)
|
||||
return this
|
||||
}
|
||||
|
||||
|
|
633
app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoModel.kt
Normal file
633
app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoModel.kt
Normal file
|
@ -0,0 +1,633 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Application
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.LocaleList
|
||||
import android.os.PowerManager
|
||||
import android.os.StatFs
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.TextTable
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.ByteOrderMark
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.StringReader
|
||||
import java.io.Writer
|
||||
import java.util.TimeZone
|
||||
import java.util.logging.Level
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter as asCalendarSyncAdapter
|
||||
import at.bitfire.vcard4android.Utils.asSyncAdapter as asContactsSyncAdapter
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter as asJtxSyncAdapter
|
||||
|
||||
@HiltViewModel(assistedFactory = DebugInfoModel.Factory::class)
|
||||
class DebugInfoModel @AssistedInject constructor(
|
||||
@Assisted private val details: DebugInfoDetails,
|
||||
val context: Application,
|
||||
val db: AppDatabase,
|
||||
val settings: SettingsManager
|
||||
) : ViewModel() {
|
||||
|
||||
data class DebugInfoDetails(
|
||||
val account: Account?,
|
||||
val authority: String?,
|
||||
val cause: Throwable?,
|
||||
val localResource: String?,
|
||||
val remoteResource: String?,
|
||||
val logs: String?
|
||||
)
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun createWithDetails(details: DebugInfoDetails): DebugInfoModel
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FILE_DEBUG_INFO = "debug-info.txt"
|
||||
private const val FILE_LOGS = "logs.txt"
|
||||
}
|
||||
|
||||
data class UiState(
|
||||
val cause: Throwable? = null,
|
||||
val localResource: String? = null,
|
||||
val remoteResource: String? = null,
|
||||
val logFile: File? = null,
|
||||
val debugInfo: File? = null,
|
||||
val zipFile: File? = null,
|
||||
val zipInProgress: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
var uiState by mutableStateOf(UiState())
|
||||
private set
|
||||
|
||||
fun resetError() {
|
||||
uiState = uiState.copy(error = null)
|
||||
}
|
||||
|
||||
fun resetZipFile() {
|
||||
uiState = uiState.copy(zipFile = null)
|
||||
}
|
||||
|
||||
init {
|
||||
// create debug info directory
|
||||
val debugDir = Logger.debugDir() ?: throw IOException("Couldn't create debug info directory")
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
// create log file from EXTRA_LOGS or log file
|
||||
if (details.logs != null) {
|
||||
val file = File(debugDir, FILE_LOGS)
|
||||
if (!file.exists() || file.canWrite()) {
|
||||
file.writer().buffered().use { writer ->
|
||||
IOUtils.copy(StringReader(details.logs), writer)
|
||||
}
|
||||
uiState = uiState.copy(logFile = file)
|
||||
} else
|
||||
Logger.log.warning("Can't write logs to $file")
|
||||
} else Logger.getDebugLogFile()?.let { debugLogFile ->
|
||||
if (debugLogFile.isFile && debugLogFile.canRead())
|
||||
uiState = uiState.copy(logFile = debugLogFile)
|
||||
}
|
||||
|
||||
uiState = uiState.copy(
|
||||
cause = details.cause,
|
||||
localResource = details.localResource,
|
||||
remoteResource = details.remoteResource
|
||||
)
|
||||
|
||||
generateDebugInfo(
|
||||
syncAccount = details.account,
|
||||
syncAuthority = details.authority,
|
||||
cause = details.cause,
|
||||
localResource = details.localResource,
|
||||
remoteResource = details.remoteResource
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates debug info and saves it to [Logger.debugDir]/[FILE_DEBUG_INFO]
|
||||
*
|
||||
* Note: Part of this method and all of it's helpers (listed below) should probably be extracted in the future
|
||||
*/
|
||||
private fun generateDebugInfo(syncAccount: Account?, syncAuthority: String?, cause: Throwable?, localResource: String?, remoteResource: String?) {
|
||||
val debugInfoFile = File(Logger.debugDir(), FILE_DEBUG_INFO)
|
||||
debugInfoFile.writer().buffered().use { writer ->
|
||||
writer.append(ByteOrderMark.UTF_BOM)
|
||||
writer.append("--- BEGIN DEBUG INFO ---\n\n")
|
||||
|
||||
// begin with most specific information
|
||||
if (syncAccount != null || syncAuthority != null) {
|
||||
writer.append("SYNCHRONIZATION INFO\n")
|
||||
if (syncAccount != null)
|
||||
writer.append("Account: $syncAccount\n")
|
||||
if (syncAuthority != null)
|
||||
writer.append("Authority: $syncAuthority\n")
|
||||
writer.append("\n")
|
||||
}
|
||||
|
||||
cause?.let {
|
||||
// Log.getStackTraceString(e) returns "" in case of UnknownHostException
|
||||
writer.append("EXCEPTION\n${ExceptionUtils.getStackTrace(cause)}\n")
|
||||
}
|
||||
|
||||
// exception details
|
||||
if (cause is DavException) {
|
||||
cause.request?.let { request ->
|
||||
writer.append("HTTP REQUEST\n$request\n")
|
||||
cause.requestBody?.let { writer.append(it) }
|
||||
writer.append("\n\n")
|
||||
}
|
||||
cause.response?.let { response ->
|
||||
writer.append("HTTP RESPONSE\n$response\n")
|
||||
cause.responseBody?.let { writer.append(it) }
|
||||
writer.append("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (localResource != null)
|
||||
writer.append("LOCAL RESOURCE\n$localResource\n\n")
|
||||
|
||||
if (remoteResource != null)
|
||||
writer.append("REMOTE RESOURCE\n$remoteResource\n\n")
|
||||
|
||||
// software info
|
||||
try {
|
||||
writer.append("SOFTWARE INFORMATION\n")
|
||||
val table = TextTable("Package", "Version", "Code", "Installer", "Notes")
|
||||
val pm = context.packageManager
|
||||
|
||||
val packageNames = mutableSetOf( // we always want info about these packages:
|
||||
BuildConfig.APPLICATION_ID, // DAVx5
|
||||
TaskProvider.ProviderName.JtxBoard.packageName, // jtx Board
|
||||
TaskProvider.ProviderName.OpenTasks.packageName, // OpenTasks
|
||||
TaskProvider.ProviderName.TasksOrg.packageName // tasks.org
|
||||
)
|
||||
// ... and info about contact and calendar provider
|
||||
for (authority in arrayOf(ContactsContract.AUTHORITY, CalendarContract.AUTHORITY))
|
||||
pm.resolveContentProvider(authority, 0)?.let { packageNames += it.packageName }
|
||||
// ... and info about contact, calendar, task-editing apps
|
||||
val dataUris = arrayOf(
|
||||
ContactsContract.Contacts.CONTENT_URI,
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority),
|
||||
TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.TasksOrg.authority)
|
||||
)
|
||||
for (uri in dataUris) {
|
||||
val viewIntent = Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(uri, /* some random ID */ 1))
|
||||
for (info in pm.queryIntentActivities(viewIntent, 0))
|
||||
packageNames += info.activityInfo.packageName
|
||||
}
|
||||
|
||||
for (packageName in packageNames)
|
||||
try {
|
||||
val info = pm.getPackageInfo(packageName, 0)
|
||||
val appInfo = info.applicationInfo
|
||||
val notes = mutableListOf<String>()
|
||||
if (!appInfo.enabled)
|
||||
notes += "disabled"
|
||||
if (appInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
|
||||
notes += "<em>on external storage</em>"
|
||||
table.addLine(
|
||||
info.packageName, info.versionName, PackageInfoCompat.getLongVersionCode(info),
|
||||
pm.getInstallerPackageName(info.packageName) ?: '—', notes.joinToString(", ")
|
||||
)
|
||||
} catch (ignored: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
writer.append(table.toString())
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get software information", e)
|
||||
}
|
||||
|
||||
// system info
|
||||
val locales: Any = LocaleList.getAdjustedDefault()
|
||||
writer.append(
|
||||
"\nSYSTEM INFORMATION\n\n" +
|
||||
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
|
||||
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" +
|
||||
"Locale(s): $locales\n" +
|
||||
"Time zone: ${TimeZone.getDefault().id}\n"
|
||||
)
|
||||
val filesPath = Environment.getDataDirectory()
|
||||
val statFs = StatFs(filesPath.path)
|
||||
writer.append("Internal memory ($filesPath): ")
|
||||
.append(FileUtils.byteCountToDisplaySize(statFs.availableBytes))
|
||||
.append(" free of ")
|
||||
.append(FileUtils.byteCountToDisplaySize(statFs.totalBytes))
|
||||
.append("\n\n")
|
||||
|
||||
// power saving
|
||||
if (Build.VERSION.SDK_INT >= 28)
|
||||
context.getSystemService<UsageStatsManager>()?.let { statsManager ->
|
||||
val bucket = statsManager.appStandbyBucket
|
||||
writer
|
||||
.append("App standby bucket: ")
|
||||
.append(
|
||||
when {
|
||||
bucket <= 5 -> "exempted (very good)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_ACTIVE -> "active (good)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_WORKING_SET -> "working set (bad: job restrictions apply)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_FREQUENT -> "frequent (bad: job restrictions apply)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_RARE -> "rare (very bad: job and network restrictions apply)"
|
||||
bucket <= UsageStatsManager.STANDBY_BUCKET_RESTRICTED -> "restricted (very bad: job and network restrictions apply)"
|
||||
else -> "$bucket"
|
||||
}
|
||||
)
|
||||
writer.append('\n')
|
||||
}
|
||||
context.getSystemService<PowerManager>()?.let { powerManager ->
|
||||
writer.append("App exempted from power saving: ")
|
||||
.append(if (powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)) "yes (good)" else "no (bad)")
|
||||
.append('\n')
|
||||
.append("System in power-save mode: ")
|
||||
.append(if (powerManager.isPowerSaveMode) "yes (restrictions apply!)" else "no")
|
||||
.append('\n')
|
||||
}
|
||||
// system-wide sync
|
||||
writer.append("System-wide synchronization: ")
|
||||
.append(if (ContentResolver.getMasterSyncAutomatically()) "automatically" else "manually")
|
||||
.append("\n\n")
|
||||
|
||||
// connectivity
|
||||
context.getSystemService<ConnectivityManager>()?.let { connectivityManager ->
|
||||
writer.append("\nCONNECTIVITY\n\n")
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
connectivityManager.allNetworks.sortedByDescending { it == activeNetwork }.forEach { network ->
|
||||
val properties = connectivityManager.getLinkProperties(network)
|
||||
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
|
||||
writer.append(if (network == activeNetwork) " ☒ " else " ☐ ")
|
||||
.append(properties?.interfaceName ?: "?")
|
||||
.append("\n - ")
|
||||
.append(capabilities.toString().replace('&', ' '))
|
||||
.append('\n')
|
||||
}
|
||||
if (properties != null) {
|
||||
writer.append(" - DNS: ")
|
||||
.append(properties.dnsServers.joinToString(", ") { it.hostAddress })
|
||||
if (Build.VERSION.SDK_INT >= 28 && properties.isPrivateDnsActive)
|
||||
writer.append(" (private mode)")
|
||||
writer.append('\n')
|
||||
}
|
||||
}
|
||||
writer.append('\n')
|
||||
|
||||
connectivityManager.defaultProxy?.let { proxy ->
|
||||
writer.append("System default proxy: ${proxy.host}:${proxy.port}\n")
|
||||
}
|
||||
writer.append("Data saver: ").append(
|
||||
when (connectivityManager.restrictBackgroundStatus) {
|
||||
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED -> "enabled"
|
||||
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED -> "whitelisted"
|
||||
ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED -> "disabled"
|
||||
else -> connectivityManager.restrictBackgroundStatus.toString()
|
||||
}
|
||||
).append('\n')
|
||||
writer.append('\n')
|
||||
}
|
||||
|
||||
writer.append("\nCONFIGURATION\n\n")
|
||||
// notifications
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
writer.append("\nNotifications")
|
||||
if (!nm.areNotificationsEnabled())
|
||||
writer.append(" (blocked!)")
|
||||
writer.append(":\n")
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
val channelsWithoutGroup = nm.notificationChannels.toMutableSet()
|
||||
for (group in nm.notificationChannelGroups) {
|
||||
writer.append(" - ${group.id}")
|
||||
if (Build.VERSION.SDK_INT >= 28)
|
||||
writer.append(" isBlocked=${group.isBlocked}")
|
||||
writer.append('\n')
|
||||
for (channel in group.channels) {
|
||||
writer.append(" * ${channel.id}: importance=${channel.importance}\n")
|
||||
channelsWithoutGroup -= channel
|
||||
}
|
||||
}
|
||||
for (channel in channelsWithoutGroup)
|
||||
writer.append(" - ${channel.id}: importance=${channel.importance}\n")
|
||||
}
|
||||
writer.append('\n')
|
||||
// permissions
|
||||
writer.append("Permissions:\n")
|
||||
val ownPkgInfo = context.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS)
|
||||
for (permission in ownPkgInfo.requestedPermissions) {
|
||||
val shortPermission = permission.removePrefix("android.permission.")
|
||||
writer.append(" - $shortPermission: ")
|
||||
.append(
|
||||
if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED)
|
||||
"granted"
|
||||
else
|
||||
"denied"
|
||||
)
|
||||
.append('\n')
|
||||
}
|
||||
writer.append('\n')
|
||||
|
||||
// main accounts
|
||||
writer.append("\nACCOUNTS\n\n")
|
||||
val accountManager = AccountManager.get(context)
|
||||
val mainAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type))
|
||||
val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList()
|
||||
for (account in mainAccounts) {
|
||||
dumpMainAccount(account, writer)
|
||||
|
||||
val iter = addressBookAccounts.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val addressBookAccount = iter.next()
|
||||
val mainAccount = Account(
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME),
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
)
|
||||
if (mainAccount == account) {
|
||||
dumpAddressBookAccount(addressBookAccount, accountManager, writer)
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (addressBookAccounts.isNotEmpty()) {
|
||||
writer.append("Address book accounts without main account:\n")
|
||||
for (account in addressBookAccounts)
|
||||
dumpAddressBookAccount(account, accountManager, writer)
|
||||
}
|
||||
|
||||
// database dump
|
||||
writer.append("\nDATABASE DUMP\n\n")
|
||||
db.dump(writer, arrayOf("webdav_document"))
|
||||
|
||||
// app settings
|
||||
writer.append("\nAPP SETTINGS\n\n")
|
||||
settings.dump(writer)
|
||||
|
||||
writer.append("--- END DEBUG INFO ---\n")
|
||||
writer.toString()
|
||||
}
|
||||
uiState = uiState.copy(debugInfo = debugInfoFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the ZIP file containing both [FILE_DEBUG_INFO] and [FILE_LOGS].
|
||||
*
|
||||
* Note: Part of this method should probably be extracted to a more suitable location
|
||||
*/
|
||||
fun generateZip() {
|
||||
try {
|
||||
uiState = uiState.copy(zipInProgress = true)
|
||||
|
||||
val file = File(Logger.debugDir(), "davx5-debug.zip")
|
||||
Logger.log.fine("Writing debug info to ${file.absolutePath}")
|
||||
ZipOutputStream(file.outputStream().buffered()).use { zip ->
|
||||
zip.setLevel(9)
|
||||
uiState.debugInfo?.let { debugInfo ->
|
||||
zip.putNextEntry(ZipEntry("debug-info.txt"))
|
||||
debugInfo.inputStream().use {
|
||||
IOUtils.copy(it, zip)
|
||||
}
|
||||
zip.closeEntry()
|
||||
}
|
||||
|
||||
val logs = uiState.logFile
|
||||
if (logs != null) {
|
||||
// verbose logs available
|
||||
zip.putNextEntry(ZipEntry(logs.name))
|
||||
logs.inputStream().use {
|
||||
IOUtils.copy(it, zip)
|
||||
}
|
||||
zip.closeEntry()
|
||||
} else {
|
||||
// logcat (short logs)
|
||||
try {
|
||||
Runtime.getRuntime().exec("logcat -d").also { logcat ->
|
||||
zip.putNextEntry(ZipEntry("logcat.txt"))
|
||||
IOUtils.copy(logcat.inputStream, zip)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't attach logcat", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// success, show ZIP file
|
||||
uiState = uiState.copy(zipFile = file)
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't generate debug info ZIP", e)
|
||||
uiState = uiState.copy(error = e.localizedMessage)
|
||||
} finally {
|
||||
uiState = uiState.copy(zipInProgress = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Appends relevant android account information the given writer.
|
||||
*
|
||||
* Note: Helper method of [generateDebugInfo].
|
||||
*/
|
||||
private fun dumpMainAccount(account: Account, writer: Writer) {
|
||||
writer.append("\n\n - Account: ${account.name}\n")
|
||||
writer.append(dumpAccount(account, AccountDumpInfo.mainAccount(context, account)))
|
||||
try {
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
|
||||
val credentials = accountSettings.credentials()
|
||||
val authStr = mutableListOf<String>()
|
||||
if (credentials.username != null)
|
||||
authStr += "user name"
|
||||
if (credentials.password != null)
|
||||
authStr += "password"
|
||||
if (credentials.certificateAlias != null)
|
||||
authStr += "client certificate"
|
||||
credentials.authState?.let { authState ->
|
||||
authStr += "OAuth [${authState.authorizationServiceConfiguration?.authorizationEndpoint}]"
|
||||
}
|
||||
if (authStr.isNotEmpty())
|
||||
writer .append(" Authentication: ")
|
||||
.append(authStr.joinToString(", "))
|
||||
.append("\n")
|
||||
|
||||
writer.append(" WiFi only: ${accountSettings.getSyncWifiOnly()}")
|
||||
accountSettings.getSyncWifiOnlySSIDs()?.let { ssids ->
|
||||
writer.append(", SSIDs: ${ssids.joinToString(", ")}")
|
||||
}
|
||||
writer.append(
|
||||
"\n Contact group method: ${accountSettings.getGroupMethod()}\n" +
|
||||
" Time range (past days): ${accountSettings.getTimeRangePastDays()}\n" +
|
||||
" Default alarm (min before): ${accountSettings.getDefaultAlarm()}\n" +
|
||||
" Manage calendar colors: ${accountSettings.getManageCalendarColors()}\n" +
|
||||
" Use event colors: ${accountSettings.getEventColors()}\n"
|
||||
)
|
||||
|
||||
writer.append("\nSync workers:\n")
|
||||
.append(dumpSyncWorkersInfo(account))
|
||||
.append("\n")
|
||||
} catch (e: InvalidAccountException) {
|
||||
writer.append("$e\n")
|
||||
}
|
||||
writer.append('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends relevant address book type android account information to the given writer.
|
||||
*
|
||||
* Note: Helper method of [generateDebugInfo].
|
||||
*/
|
||||
private fun dumpAddressBookAccount(account: Account, accountManager: AccountManager, writer: Writer) {
|
||||
writer.append(" * Address book: ${account.name}\n")
|
||||
val table = dumpAccount(account, AccountDumpInfo.addressBookAccount(account))
|
||||
writer.append(TextTable.indent(table, 4))
|
||||
.append("URL: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_URL)}\n")
|
||||
.append(" Read-only: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_READ_ONLY) ?: 0}\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves specified information from an android account
|
||||
*
|
||||
* Note: Helper method of [generateDebugInfo].
|
||||
*
|
||||
* @return the requested information
|
||||
*/
|
||||
private fun dumpAccount(account: Account, infos: Iterable<AccountDumpInfo>): String {
|
||||
val table = TextTable("Authority", "isSyncable", "syncAutomatically", "Interval", "Entries")
|
||||
for (info in infos) {
|
||||
var nrEntries = "—"
|
||||
if (info.countUri != null)
|
||||
try {
|
||||
context.contentResolver.acquireContentProviderClient(info.authority)?.use { client ->
|
||||
client.query(info.countUri, null, null, null, null)?.use { cursor ->
|
||||
nrEntries = "${cursor.count} ${info.countStr}"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
nrEntries = e.toString()
|
||||
}
|
||||
val accountSettings = AccountSettings(context, account)
|
||||
table.addLine(
|
||||
info.authority,
|
||||
ContentResolver.getIsSyncable(account, info.authority),
|
||||
ContentResolver.getSyncAutomatically(account, info.authority), // content-triggered sync
|
||||
accountSettings.getSyncInterval(info.authority)?.let {"${it/60} min"},
|
||||
nrEntries
|
||||
)
|
||||
}
|
||||
return table.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets sync workers info
|
||||
* Note: WorkManager does not return worker names when queried, so we create them and ask
|
||||
* whether they exist one by one
|
||||
*/
|
||||
private fun dumpSyncWorkersInfo(account: Account): String {
|
||||
val table = TextTable("Tags", "Authority", "State", "Next run", "Retries", "Generation", "Periodicity")
|
||||
listOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY,
|
||||
TaskProvider.ProviderName.JtxBoard.authority,
|
||||
TaskProvider.ProviderName.OpenTasks.authority,
|
||||
TaskProvider.ProviderName.TasksOrg.authority
|
||||
).forEach { authority ->
|
||||
val tag = BaseSyncWorker.commonTag(account, authority)
|
||||
WorkManager.getInstance(context).getWorkInfos(
|
||||
WorkQuery.Builder.fromTags(listOf(tag)).build()
|
||||
).get().forEach { workInfo ->
|
||||
table.addLine(
|
||||
workInfo.tags.map { it.replace("\\bat\\.bitfire\\.davdroid\\.".toRegex(), ".") },
|
||||
authority,
|
||||
"${workInfo.state} (${workInfo.stopReason})",
|
||||
workInfo.nextScheduleTimeMillis.let { nextRun ->
|
||||
when (nextRun) {
|
||||
Long.MAX_VALUE -> "—"
|
||||
else -> DateUtils.getRelativeTimeSpanString(nextRun)
|
||||
}
|
||||
},
|
||||
workInfo.runAttemptCount,
|
||||
workInfo.generation,
|
||||
workInfo.periodicityInfo?.let { periodicity ->
|
||||
"every ${periodicity.repeatIntervalMillis/60000} min"
|
||||
} ?: "not periodic"
|
||||
)
|
||||
}
|
||||
}
|
||||
return table.toString()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
data class AccountDumpInfo(
|
||||
val account: Account,
|
||||
val authority: String,
|
||||
val countUri: Uri?,
|
||||
val countStr: String?,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun mainAccount(context: Context, account: Account) = listOf(
|
||||
AccountDumpInfo(account, context.getString(R.string.address_books_authority), null, null),
|
||||
AccountDumpInfo(account, CalendarContract.AUTHORITY, CalendarContract.Events.CONTENT_URI.asCalendarSyncAdapter(account), "event(s)"),
|
||||
AccountDumpInfo(account, TaskProvider.ProviderName.JtxBoard.authority, JtxContract.JtxICalObject.CONTENT_URI.asJtxSyncAdapter(account), "jtx Board ICalObject(s)"),
|
||||
AccountDumpInfo(account, TaskProvider.ProviderName.OpenTasks.authority, TaskContract.Tasks.getContentUri(
|
||||
TaskProvider.ProviderName.OpenTasks.authority).asCalendarSyncAdapter(account), "OpenTasks task(s)"),
|
||||
AccountDumpInfo(account, TaskProvider.ProviderName.TasksOrg.authority, TaskContract.Tasks.getContentUri(
|
||||
TaskProvider.ProviderName.TasksOrg.authority).asCalendarSyncAdapter(account), "tasks.org task(s)"),
|
||||
AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "wrongly assigned raw contact(s)")
|
||||
)
|
||||
|
||||
fun addressBookAccount(account: Account) = listOf(
|
||||
AccountDumpInfo(account, ContactsContract.AUTHORITY, ContactsContract.RawContacts.CONTENT_URI.asContactsSyncAdapter(account), "raw contact(s)")
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
316
app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt
Normal file
316
app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt
Normal file
|
@ -0,0 +1,316 @@
|
|||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.Account
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Adb
|
||||
import androidx.compose.material.icons.rounded.BugReport
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.composable.CardWithImage
|
||||
import java.io.File
|
||||
import java.io.IOError
|
||||
import java.io.IOException
|
||||
|
||||
@Composable
|
||||
fun DebugInfoScreen(
|
||||
account: Account?,
|
||||
authority: String?,
|
||||
cause: Throwable?,
|
||||
localResource: String?,
|
||||
remoteResource: String?,
|
||||
logs: String?,
|
||||
onShareZipFile: (File) -> Unit,
|
||||
onViewFile: (File) -> Unit,
|
||||
onNavUp: () -> Unit
|
||||
) {
|
||||
val model: DebugInfoModel = hiltViewModel(
|
||||
creationCallback = { factory: DebugInfoModel.Factory ->
|
||||
factory.createWithDetails(DebugInfoModel.DebugInfoDetails(
|
||||
account = account,
|
||||
authority = authority,
|
||||
cause = cause,
|
||||
localResource = localResource,
|
||||
remoteResource = remoteResource,
|
||||
logs = logs
|
||||
))
|
||||
}
|
||||
)
|
||||
|
||||
val uiState = model.uiState
|
||||
val debugInfo = uiState.debugInfo
|
||||
val zipInProgress = uiState.zipInProgress
|
||||
val zipFile = uiState.zipFile
|
||||
val logFile = uiState.logFile
|
||||
val error = uiState.error
|
||||
|
||||
// Share zip file card, once successfully generated
|
||||
LaunchedEffect(zipFile) {
|
||||
zipFile?.let { file ->
|
||||
onShareZipFile(file)
|
||||
model.resetZipFile()
|
||||
}
|
||||
}
|
||||
|
||||
DebugInfoScreen(
|
||||
error = error,
|
||||
onResetError = model::resetError,
|
||||
showDebugInfo = debugInfo != null,
|
||||
zipProgress = zipInProgress,
|
||||
showModelCause = cause != null,
|
||||
modelCauseTitle = when (cause) {
|
||||
is HttpException -> stringResource(if (cause.code / 100 == 5) R.string.debug_info_server_error else R.string.debug_info_http_error)
|
||||
is DavException -> stringResource(R.string.debug_info_webdav_error)
|
||||
is IOException, is IOError -> stringResource(R.string.debug_info_io_error)
|
||||
else -> cause?.let { it::class.java.simpleName }
|
||||
} ?: "",
|
||||
modelCauseSubtitle = cause?.localizedMessage,
|
||||
modelCauseMessage = stringResource(
|
||||
if (cause is HttpException)
|
||||
when {
|
||||
cause.code == 403 -> R.string.debug_info_http_403_description
|
||||
cause.code == 404 -> R.string.debug_info_http_404_description
|
||||
cause.code / 100 == 5 -> R.string.debug_info_http_5xx_description
|
||||
else -> R.string.debug_info_unexpected_error
|
||||
}
|
||||
else
|
||||
R.string.debug_info_unexpected_error
|
||||
),
|
||||
localResource = localResource,
|
||||
remoteResource = remoteResource,
|
||||
hasLogFile = logFile != null,
|
||||
onShareZip = { model.generateZip() },
|
||||
onViewLogsFile = { logFile?.let { onViewFile(it) } },
|
||||
onViewDebugFile = { debugInfo?.let { onViewFile(it) } },
|
||||
onNavUp = onNavUp
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DebugInfoScreen(
|
||||
error: String?,
|
||||
onResetError: () -> Unit = {},
|
||||
showDebugInfo: Boolean,
|
||||
zipProgress: Boolean,
|
||||
showModelCause: Boolean,
|
||||
modelCauseTitle: String,
|
||||
modelCauseSubtitle: String?,
|
||||
modelCauseMessage: String?,
|
||||
localResource: String?,
|
||||
remoteResource: String?,
|
||||
hasLogFile: Boolean,
|
||||
onShareZip: () -> Unit = {},
|
||||
onViewLogsFile: () -> Unit = {},
|
||||
onViewDebugFile: () -> Unit = {},
|
||||
onNavUp: () -> Unit = {}
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(error) {
|
||||
error?.let {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = it,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
onResetError()
|
||||
}
|
||||
}
|
||||
|
||||
AppTheme {
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
if (showDebugInfo && !zipProgress) {
|
||||
FloatingActionButton(
|
||||
onClick = onShareZip
|
||||
) {
|
||||
Icon(Icons.Rounded.Share, stringResource(R.string.share))
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
},
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.debug_info_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavUp) {
|
||||
androidx.compose.material.Icon(
|
||||
Icons.AutoMirrored.Default.ArrowBack,
|
||||
stringResource(R.string.navigate_up)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
if (!showDebugInfo || zipProgress)
|
||||
LinearProgressIndicator()
|
||||
|
||||
if (showModelCause) {
|
||||
CardWithImage(
|
||||
title = modelCauseTitle,
|
||||
subtitle = modelCauseSubtitle,
|
||||
message = modelCauseMessage,
|
||||
icon = Icons.Rounded.Info,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (showDebugInfo)
|
||||
CardWithImage(
|
||||
image = painterResource(R.drawable.undraw_server_down),
|
||||
imageAlignment = BiasAlignment(0f, .7f),
|
||||
title = stringResource(R.string.debug_info_title),
|
||||
subtitle = stringResource(R.string.debug_info_subtitle),
|
||||
icon = Icons.Rounded.BugReport,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onViewDebugFile,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.debug_info_view_details)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (localResource != null || remoteResource != null)
|
||||
CardWithImage(
|
||||
title = stringResource(R.string.debug_info_involved_caption),
|
||||
subtitle = stringResource(R.string.debug_info_involved_subtitle),
|
||||
icon = Icons.Rounded.Adb,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
remoteResource?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_info_involved_remote),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
localResource?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.debug_info_involved_local),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLogFile) {
|
||||
CardWithImage(
|
||||
title = stringResource(R.string.debug_info_logs_caption),
|
||||
subtitle = stringResource(R.string.debug_info_logs_subtitle),
|
||||
icon = Icons.Rounded.BugReport,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onViewLogsFile,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.debug_info_logs_view)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDebugInfo) {
|
||||
CardWithImage(
|
||||
title = stringResource(R.string.debug_info_archive_caption),
|
||||
subtitle = stringResource(R.string.debug_info_archive_subtitle),
|
||||
message = stringResource(R.string.debug_info_archive_text),
|
||||
icon = Icons.Rounded.Share,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onShareZip,
|
||||
enabled = !zipProgress,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.debug_info_archive_share)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// space for the FAB
|
||||
Spacer(modifier = Modifier.height(64.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun DebugInfoScreen_Preview() {
|
||||
DebugInfoScreen(
|
||||
error = "Some error",
|
||||
showDebugInfo = true,
|
||||
zipProgress = false,
|
||||
showModelCause = true,
|
||||
modelCauseTitle = "ModelCauseTitle",
|
||||
modelCauseSubtitle = "ModelCauseSubtitle",
|
||||
modelCauseMessage = "ModelCauseMessage",
|
||||
localResource = "local-resource-string",
|
||||
remoteResource = "remote-resource-string",
|
||||
hasLogFile = true
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user