mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-07-22 11:11:02 +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.CurrentUserPrivilegeSet
|
||||||
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||||
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
|
||||||
import at.bitfire.davdroid.util.lastSegment
|
import at.bitfire.davdroid.util.lastSegment
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
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.Collection
|
||||||
import at.bitfire.davdroid.db.SyncState
|
import at.bitfire.davdroid.db.SyncState
|
||||||
import at.bitfire.davdroid.log.Logger
|
import at.bitfire.davdroid.log.Logger
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
|
||||||
import at.bitfire.davdroid.util.lastSegment
|
import at.bitfire.davdroid.util.lastSegment
|
||||||
import at.bitfire.ical4android.AndroidCalendar
|
import at.bitfire.ical4android.AndroidCalendar
|
||||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
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.Principal
|
||||||
import at.bitfire.davdroid.db.SyncState
|
import at.bitfire.davdroid.db.SyncState
|
||||||
import at.bitfire.davdroid.log.Logger
|
import at.bitfire.davdroid.log.Logger
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
|
||||||
import at.bitfire.davdroid.util.lastSegment
|
import at.bitfire.davdroid.util.lastSegment
|
||||||
import at.bitfire.ical4android.JtxCollection
|
import at.bitfire.ical4android.JtxCollection
|
||||||
import at.bitfire.ical4android.JtxCollectionFactory
|
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.Collection
|
||||||
import at.bitfire.davdroid.db.SyncState
|
import at.bitfire.davdroid.db.SyncState
|
||||||
import at.bitfire.davdroid.log.Logger
|
import at.bitfire.davdroid.log.Logger
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
|
||||||
import at.bitfire.davdroid.util.lastSegment
|
import at.bitfire.davdroid.util.lastSegment
|
||||||
import at.bitfire.ical4android.DmfsTaskList
|
import at.bitfire.ical4android.DmfsTaskList
|
||||||
import at.bitfire.ical4android.DmfsTaskListFactory
|
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.LocalEvent
|
||||||
import at.bitfire.davdroid.resource.LocalResource
|
import at.bitfire.davdroid.resource.LocalResource
|
||||||
import at.bitfire.davdroid.settings.AccountSettings
|
import at.bitfire.davdroid.settings.AccountSettings
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
|
||||||
import at.bitfire.davdroid.util.lastSegment
|
import at.bitfire.davdroid.util.lastSegment
|
||||||
import at.bitfire.ical4android.Event
|
import at.bitfire.ical4android.Event
|
||||||
import at.bitfire.ical4android.InvalidCalendarException
|
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.LocalJtxICalObject
|
||||||
import at.bitfire.davdroid.resource.LocalResource
|
import at.bitfire.davdroid.resource.LocalResource
|
||||||
import at.bitfire.davdroid.settings.AccountSettings
|
import at.bitfire.davdroid.settings.AccountSettings
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
|
||||||
import at.bitfire.davdroid.util.lastSegment
|
import at.bitfire.davdroid.util.lastSegment
|
||||||
import at.bitfire.ical4android.InvalidCalendarException
|
import at.bitfire.ical4android.InvalidCalendarException
|
||||||
import at.bitfire.ical4android.JtxICalObject
|
import at.bitfire.ical4android.JtxICalObject
|
||||||
|
|
|
@ -846,7 +846,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||||
.withCause(e)
|
.withCause(e)
|
||||||
.withLocalResource(
|
.withLocalResource(
|
||||||
try {
|
try {
|
||||||
local.toString()
|
local?.toString()
|
||||||
} catch (e: OutOfMemoryError) {
|
} catch (e: OutOfMemoryError) {
|
||||||
// for instance because of a huge contact photo; maybe we're lucky and can fetch it
|
// for instance because of a huge contact photo; maybe we're lucky and can fetch it
|
||||||
null
|
null
|
||||||
|
|
|
@ -24,7 +24,6 @@ import at.bitfire.davdroid.resource.LocalResource
|
||||||
import at.bitfire.davdroid.resource.LocalTask
|
import at.bitfire.davdroid.resource.LocalTask
|
||||||
import at.bitfire.davdroid.resource.LocalTaskList
|
import at.bitfire.davdroid.resource.LocalTaskList
|
||||||
import at.bitfire.davdroid.settings.AccountSettings
|
import at.bitfire.davdroid.settings.AccountSettings
|
||||||
import at.bitfire.davdroid.util.DavUtils
|
|
||||||
import at.bitfire.davdroid.util.lastSegment
|
import at.bitfire.davdroid.util.lastSegment
|
||||||
import at.bitfire.ical4android.InvalidCalendarException
|
import at.bitfire.ical4android.InvalidCalendarException
|
||||||
import at.bitfire.ical4android.Task
|
import at.bitfire.ical4android.Task
|
||||||
|
|
|
@ -3,116 +3,22 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package at.bitfire.davdroid.ui
|
package at.bitfire.davdroid.ui
|
||||||
|
|
||||||
import android.accounts.Account
|
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.Context
|
||||||
import android.content.Intent
|
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.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.compose.setContent
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.app.ShareCompat
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.FileProvider
|
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.BuildConfig
|
||||||
import at.bitfire.davdroid.InvalidAccountException
|
|
||||||
import at.bitfire.davdroid.R
|
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 dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import org.apache.commons.io.ByteOrderMark
|
|
||||||
import org.apache.commons.io.FileUtils
|
import org.apache.commons.io.FileUtils
|
||||||
import org.apache.commons.io.IOUtils
|
|
||||||
import org.apache.commons.lang3.StringUtils
|
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.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
|
* 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]) */
|
/** URL of remote resource related to the problem (plain-text [String]) */
|
||||||
private const val EXTRA_REMOTE_RESOURCE = "remoteResource"
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
val extras = intent.extras
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
M2Theme {
|
DebugInfoScreen(
|
||||||
val debugInfo by model.debugInfo.observeAsState()
|
account = extras?.getParcelable(EXTRA_ACCOUNT),
|
||||||
val zipProgress by model.zipProgress.observeAsState(false)
|
authority = extras?.getString(EXTRA_AUTHORITY),
|
||||||
|
cause = extras?.getSerializable(EXTRA_CAUSE) as? Throwable,
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
localResource = extras?.getString(EXTRA_LOCAL_RESOURCE),
|
||||||
|
remoteResource = extras?.getString(EXTRA_REMOTE_RESOURCE),
|
||||||
Scaffold(
|
logs = extras?.getString(EXTRA_LOGS),
|
||||||
floatingActionButton = {
|
onShareZipFile = ::shareZipFile,
|
||||||
if (debugInfo != null && !zipProgress) {
|
onViewFile = ::viewFile,
|
||||||
FloatingActionButton(
|
onNavUp = ::onSupportNavigateUp
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private fun shareZipFile(file: File) {
|
||||||
fun Content(paddingValues: PaddingValues) {
|
shareFile(
|
||||||
val debugInfo by model.debugInfo.observeAsState()
|
file,
|
||||||
val zipProgress by model.zipProgress.observeAsState(false)
|
"${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME} debug info",
|
||||||
val modelCause by model.cause.observeAsState()
|
getString(R.string.debug_info_attached),
|
||||||
val localResource by model.localResource.observeAsState()
|
"*/*", // application/zip won't show all apps that can manage binary files, like ShareViaHttp
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts an activity passing sharing intent along
|
||||||
|
*/
|
||||||
private fun shareFile(
|
private fun shareFile(
|
||||||
file: File,
|
file: File,
|
||||||
subject: String? = null,
|
subject: String? = null,
|
||||||
|
@ -392,6 +103,9 @@ class DebugInfoActivity : AppCompatActivity() {
|
||||||
.startChooser()
|
.startChooser()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts an activity passing file viewer intent along
|
||||||
|
*/
|
||||||
private fun viewFile(
|
private fun viewFile(
|
||||||
file: File,
|
file: File,
|
||||||
title: String? = null
|
title: String? = null
|
||||||
|
@ -409,521 +123,9 @@ class DebugInfoActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ReportModel @AssistedInject constructor (
|
/**
|
||||||
val context: Application,
|
* Builder for [DebugInfoActivity] intents
|
||||||
@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)")
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class IntentBuilder(context: Context) {
|
class IntentBuilder(context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -957,13 +159,19 @@ class DebugInfoActivity : AppCompatActivity() {
|
||||||
|
|
||||||
fun withLocalResource(dump: String?): IntentBuilder {
|
fun withLocalResource(dump: String?): IntentBuilder {
|
||||||
if (dump != null)
|
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
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun withLogs(logs: String?): IntentBuilder {
|
fun withLogs(logs: String?): IntentBuilder {
|
||||||
if (logs != null)
|
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
|
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 a new issue