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:
Sunik Kupfer 2024-05-15 16:43:23 +02:00 committed by GitHub
parent b5334887e8
commit 86252f9117
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 987 additions and 837 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}

View 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)")
)
}
}

View 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
)
}