diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt index 9af417d5..d8c74425 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt index 433845d5..e058c9d3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalCalendar.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt index 329c2c3c..e0fff772 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalJtxCollection.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt index 1eeec432..3c17997d 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTaskList.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt index 75a8ac47..f0127443 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt index 76350bac..355d0dd7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/JtxSyncManager.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt index d4643184..af5e5a46 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt @@ -846,7 +846,7 @@ abstract class SyncManager, 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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt index ff283bcd..03d0328a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt @@ -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 diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt index 57ee55c4..5abf946a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoActivity.kt @@ -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 { - object: ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): 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() - var logFile = MutableLiveData() - val localResource = MutableLiveData() - val remoteResource = MutableLiveData() - val debugInfo = MutableLiveData() - - // feedback for UI - val zipProgress = MutableLiveData(false) - val zipFile = MutableLiveData() - val error = MutableLiveData() - - 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() - if (!appInfo.enabled) - notes += "disabled" - if (appInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0) - notes += "on external storage" - 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()?.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()?.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()?.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() - 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): 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 } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoModel.kt new file mode 100644 index 00000000..d4629a5d --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoModel.kt @@ -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() + if (!appInfo.enabled) + notes += "disabled" + if (appInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0) + notes += "on external storage" + 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()?.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()?.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()?.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() + 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): 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)") + ) + + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt new file mode 100644 index 00000000..7f489856 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/DebugInfoScreen.kt @@ -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 + ) +} \ No newline at end of file