Add log viewer (#686)

This commit is contained in:
Mikołaj Pich 2020-02-22 21:24:06 +01:00 committed by GitHub
parent 9a87df7315
commit 00f5b9431e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 361 additions and 27 deletions

View File

@ -15,7 +15,7 @@ android {
defaultConfig {
applicationId "io.github.wulkanowy"
testApplicationId "io.github.tests.wulkanowy"
minSdkVersion 16
minSdkVersion 17
targetSdkVersion 29
versionCode 52
versionName "0.15.0"
@ -173,6 +173,7 @@ dependencies {
implementation "com.jakewharton.threetenabp:threetenabp:1.2.2"
implementation "com.jakewharton.timber:timber:4.7.1"
implementation "at.favre.lib:slf4j-timber:1.0.1"
implementation "fr.bipi.treessence:treessence:0.3.0"
implementation "com.mikepenz:aboutlibraries-core:7.1.0"
implementation 'com.wdullaer:materialdatetimepicker:4.2.3'

View File

@ -5,13 +5,12 @@ package io.github.wulkanowy.utils
import android.content.Context
import timber.log.Timber
fun initCrashlytics(context: Context, appInfo: AppInfo) {
// do nothing
fun initCrashlytics(context: Context, appInfo: AppInfo) {}
open class TimberTreeNoOp : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {}
}
class CrashlyticsTree : Timber.Tree() {
class CrashlyticsTree : TimberTreeNoOp()
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
// do nothing
}
}
class CrashlyticsExceptionTree : TimberTreeNoOp()

View File

@ -43,8 +43,8 @@
android:name=".ui.modules.message.send.SendMessageActivity"
android:configChanges="orientation|screenSize"
android:label="@string/send_message_title"
android:windowSoftInputMode="adjustResize"
android:theme="@style/WulkanowyTheme.NoActionBar" />
android:theme="@style/WulkanowyTheme.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.modules.timetablewidget.TimetableWidgetConfigureActivity"
android:excludeFromRecents="true"
@ -96,6 +96,16 @@
android:exported="false"
tools:node="remove" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<meta-data
android:name="io.fabric.ApiKey"
android:value="${fabric_api_key}" />

View File

@ -1,6 +1,7 @@
package io.github.wulkanowy
import android.content.Context
import android.util.Log.DEBUG
import android.util.Log.INFO
import android.util.Log.VERBOSE
import androidx.multidex.MultiDex
@ -11,11 +12,13 @@ import dagger.android.AndroidInjector
import dagger.android.support.DaggerApplication
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.utils.Log
import fr.bipi.tressence.file.FileLoggerTree
import io.github.wulkanowy.di.DaggerAppComponent
import io.github.wulkanowy.services.sync.SyncWorkerFactory
import io.github.wulkanowy.ui.base.ThemeManager
import io.github.wulkanowy.utils.ActivityLifecycleLogger
import io.github.wulkanowy.utils.AppInfo
import io.github.wulkanowy.utils.CrashlyticsExceptionTree
import io.github.wulkanowy.utils.CrashlyticsTree
import io.github.wulkanowy.utils.DebugLogTree
import io.github.wulkanowy.utils.initCrashlytics
@ -54,9 +57,17 @@ class WulkanowyApp : DaggerApplication(), Configuration.Provider {
private fun initLogging() {
if (appInfo.isDebug) {
Timber.plant(DebugLogTree())
FlexibleAdapter.enableLogs(Log.Level.DEBUG)
Timber.plant(DebugLogTree())
Timber.plant(FileLoggerTree.Builder()
.withFileName("wulkanowy.%g.log")
.withDirName(applicationContext.filesDir.absolutePath)
.withFileLimit(10)
.withMinPriority(DEBUG)
.build()
)
} else {
Timber.plant(CrashlyticsExceptionTree())
Timber.plant(CrashlyticsTree())
}
registerActivityLifecycleCallbacks(ActivityLifecycleLogger())

View File

@ -0,0 +1,39 @@
package io.github.wulkanowy.data.repositories.logger
import android.content.Context
import io.reactivex.Single
import java.io.File
import java.io.FileNotFoundException
import javax.inject.Inject
class LoggerRepository @Inject constructor(private val context: Context) {
fun getLastLogLines(): Single<List<String>> {
return getLastModified()
.map { it.readText() }
.map { it.split("\n") }
}
fun getLogFiles(): Single<List<File>> {
return Single.fromCallable {
File(context.filesDir.absolutePath).listFiles(File::isFile)?.filter {
it.name.endsWith(".log")
}
}
}
private fun getLastModified(): Single<File> {
return Single.fromCallable {
var lastModifiedTime = Long.MIN_VALUE
var chosenFile: File? = null
File(context.filesDir.absolutePath).listFiles(File::isFile)?.forEach { file ->
if (file.lastModified() > lastModifiedTime) {
lastModifiedTime = file.lastModified()
chosenFile = file
}
}
if (chosenFile == null) throw FileNotFoundException("Log file not found")
chosenFile
}
}
}

View File

@ -18,6 +18,7 @@ import io.github.wulkanowy.R
import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.about.creator.CreatorFragment
import io.github.wulkanowy.ui.modules.about.license.LicenseFragment
import io.github.wulkanowy.ui.modules.about.logviewer.LogViewerFragment
import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.utils.AppInfo
@ -110,6 +111,10 @@ class AboutFragment : BaseFragment(), AboutView, MainView.TitledView {
}
}
override fun openLogViewer() {
if (appInfo.isDebug) (activity as? MainActivity)?.pushView(LogViewerFragment.newInstance())
}
override fun openDiscordInvite() {
context?.openInternetBrowser("https://discord.gg/vccAQBr", ::showMessage)
}

View File

@ -27,6 +27,11 @@ class AboutPresenter @Inject constructor(
if (item !is AboutItem) return
view?.run {
when (item.title) {
versionRes?.first -> {
Timber.i("Opening log viewer")
openLogViewer()
analytics.logEvent("about_open", "name" to "log_viewer")
}
feedbackRes?.first -> {
Timber.i("Opening email client")
openEmailClient()

View File

@ -25,6 +25,8 @@ interface AboutView : BaseView {
fun updateData(header: AboutScrollableHeader, items: List<AboutItem>)
fun openLogViewer()
fun openDiscordInvite()
fun openEmailClient()

View File

@ -0,0 +1,22 @@
package io.github.wulkanowy.ui.modules.about.logviewer
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
class LogViewerAdapter : RecyclerView.Adapter<LogViewerAdapter.ViewHolder>() {
var lines = emptyList<String>()
class ViewHolder(val textView: TextView) : RecyclerView.ViewHolder(textView)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(TextView(parent.context))
}
override fun getItemCount() = lines.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.textView.text = lines[position]
}
}

View File

@ -0,0 +1,98 @@
package io.github.wulkanowy.ui.modules.about.logviewer
import android.content.Intent
import android.content.Intent.EXTRA_EMAIL
import android.content.Intent.EXTRA_STREAM
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.LOLLIPOP
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.core.content.FileProvider
import androidx.recyclerview.widget.LinearLayoutManager
import io.github.wulkanowy.BuildConfig.APPLICATION_ID
import io.github.wulkanowy.R
import io.github.wulkanowy.ui.base.BaseFragment
import io.github.wulkanowy.ui.modules.main.MainView
import kotlinx.android.synthetic.main.fragment_logviewer.*
import java.io.File
import javax.inject.Inject
class LogViewerFragment : BaseFragment(), LogViewerView, MainView.TitledView {
@Inject
lateinit var presenter: LogViewerPresenter
private val logAdapter = LogViewerAdapter()
override val titleStringId: Int
get() = R.string.logviewer_title
companion object {
fun newInstance() = LogViewerFragment()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_logviewer, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
messageContainer = logViewerRecycler
presenter.onAttachView(this)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.action_menu_logviewer, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == R.id.logViewerMenuShare) presenter.onShareLogsSelected()
else false
}
override fun initView() {
with(logViewerRecycler) {
layoutManager = LinearLayoutManager(context)
adapter = logAdapter
}
logViewRefreshButton.setOnClickListener { presenter.onRefreshClick() }
}
override fun setLines(lines: List<String>) {
logAdapter.lines = lines
logAdapter.notifyDataSetChanged()
logViewerRecycler.scrollToPosition(lines.size - 1)
}
override fun shareLogs(files: List<File>) {
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "text/plain"
putExtra(EXTRA_EMAIL, arrayOf("wulkanowyinc@gmail.com"))
addFlags(FLAG_GRANT_READ_URI_PERMISSION)
putParcelableArrayListExtra(EXTRA_STREAM, ArrayList(files.map {
if (SDK_INT < LOLLIPOP) Uri.fromFile(it)
else FileProvider.getUriForFile(requireContext(), "$APPLICATION_ID.fileprovider", it)
}))
}
startActivity(Intent.createChooser(intent, getString(R.string.logviewer_share)))
}
override fun onDestroyView() {
presenter.onDetachView()
super.onDestroyView()
}
}

View File

@ -0,0 +1,54 @@
package io.github.wulkanowy.ui.modules.about.logviewer
import io.github.wulkanowy.data.repositories.logger.LoggerRepository
import io.github.wulkanowy.data.repositories.student.StudentRepository
import io.github.wulkanowy.ui.base.BasePresenter
import io.github.wulkanowy.ui.base.ErrorHandler
import io.github.wulkanowy.utils.SchedulersProvider
import timber.log.Timber
import javax.inject.Inject
class LogViewerPresenter @Inject constructor(
schedulers: SchedulersProvider,
errorHandler: ErrorHandler,
studentRepository: StudentRepository,
private val loggerRepository: LoggerRepository
) : BasePresenter<LogViewerView>(errorHandler, studentRepository, schedulers) {
override fun onAttachView(view: LogViewerView) {
super.onAttachView(view)
view.initView()
loadLogFile()
}
fun onShareLogsSelected(): Boolean {
disposable.add(loggerRepository.getLogFiles()
.subscribeOn(schedulers.backgroundThread)
.observeOn(schedulers.mainThread)
.subscribe({
Timber.i("Loading logs files result: ${it.joinToString { it.name }}")
view?.shareLogs(it)
}, {
Timber.i("Loading logs files result: An exception occurred")
errorHandler.dispatch(it)
}))
return true
}
fun onRefreshClick() {
loadLogFile()
}
private fun loadLogFile() {
disposable.add(loggerRepository.getLastLogLines()
.subscribeOn(schedulers.backgroundThread)
.observeOn(schedulers.mainThread)
.subscribe({
Timber.i("Loading last log file result: load ${it.size} lines")
view?.setLines(it)
}, {
Timber.i("Loading last log file result: An exception occurred")
errorHandler.dispatch(it)
}))
}
}

View File

@ -0,0 +1,13 @@
package io.github.wulkanowy.ui.modules.about.logviewer
import io.github.wulkanowy.ui.base.BaseView
import java.io.File
interface LogViewerView : BaseView {
fun initView()
fun setLines(lines: List<String>)
fun shareLogs(files: List<File>)
}

View File

@ -11,6 +11,7 @@ import io.github.wulkanowy.ui.modules.about.AboutFragment
import io.github.wulkanowy.ui.modules.about.creator.CreatorFragment
import io.github.wulkanowy.ui.modules.about.license.LicenseFragment
import io.github.wulkanowy.ui.modules.about.license.LicenseModule
import io.github.wulkanowy.ui.modules.about.logviewer.LogViewerFragment
import io.github.wulkanowy.ui.modules.account.AccountDialog
import io.github.wulkanowy.ui.modules.attendance.AttendanceFragment
import io.github.wulkanowy.ui.modules.attendance.AttendanceModule
@ -121,6 +122,10 @@ abstract class MainModule {
@ContributesAndroidInjector(modules = [LicenseModule::class])
abstract fun bindLicenseFragment(): LicenseFragment
@PerFragment
@ContributesAndroidInjector
abstract fun bindLogViewerFragment(): LogViewerFragment
@PerFragment
@ContributesAndroidInjector()
abstract fun bindCreatorsFragment(): CreatorFragment

View File

@ -18,6 +18,8 @@ class DebugLogTree : Timber.DebugTree() {
}
}
private fun Bundle?.checkSavedState() = if (this == null) "(STATE IS NULL)" else ""
class ActivityLifecycleLogger : Application.ActivityLifecycleCallbacks {
override fun onActivityPaused(activity: Activity?) {
@ -45,7 +47,7 @@ class ActivityLifecycleLogger : Application.ActivityLifecycleCallbacks {
}
override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) {
activity?.let { Timber.d("${it::class.java.simpleName} CREATED ${checkSavedState(savedInstanceState)}") }
activity?.let { Timber.d("${it::class.java.simpleName} CREATED ${savedInstanceState.checkSavedState()}") }
}
}
@ -53,7 +55,7 @@ class ActivityLifecycleLogger : Application.ActivityLifecycleCallbacks {
class FragmentLifecycleLogger @Inject constructor() : FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentViewCreated(fm: FragmentManager, f: Fragment, v: View, savedInstanceState: Bundle?) {
Timber.d("${f::class.java.simpleName} VIEW CREATED ${checkSavedState(savedInstanceState)}")
Timber.d("${f::class.java.simpleName} VIEW CREATED ${savedInstanceState.checkSavedState()}")
}
override fun onFragmentStopped(fm: FragmentManager, f: Fragment) {
@ -61,7 +63,7 @@ class FragmentLifecycleLogger @Inject constructor() : FragmentManager.FragmentLi
}
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
Timber.d("${f::class.java.simpleName} CREATED ${checkSavedState(savedInstanceState)}")
Timber.d("${f::class.java.simpleName} CREATED ${savedInstanceState.checkSavedState()}")
}
override fun onFragmentResumed(fm: FragmentManager, f: Fragment) {
@ -89,7 +91,7 @@ class FragmentLifecycleLogger @Inject constructor() : FragmentManager.FragmentLi
}
override fun onFragmentActivityCreated(fm: FragmentManager, f: Fragment, savedInstanceState: Bundle?) {
Timber.d("${f::class.java.simpleName} ACTIVITY CREATED ${checkSavedState(savedInstanceState)}")
Timber.d("${f::class.java.simpleName} ACTIVITY CREATED ${savedInstanceState.checkSavedState()}")
}
override fun onFragmentPaused(fm: FragmentManager, f: Fragment) {
@ -100,5 +102,3 @@ class FragmentLifecycleLogger @Inject constructor() : FragmentManager.FragmentLi
Timber.d("${f::class.java.simpleName} DETACHED")
}
}
private fun checkSavedState(savedInstanceState: Bundle?) = if (savedInstanceState == null) "(STATE IS NULL)" else ""

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View File

@ -0,0 +1,34 @@
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.modules.about.logviewer.LogViewerFragment">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="3dp">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/logViewerRecycler"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</HorizontalScrollView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/logViewRefreshButton"
style="@style/Widget.MaterialComponents.FloatingActionButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:clickable="true"
android:focusable="true"
android:text="@string/logviewer_refresh"
android:tint="?colorOnSecondary"
app:srcCompat="@drawable/ic_refresh" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,11 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/logViewerMenuShare"
android:icon="@drawable/chuck_ic_share_white_24dp"
android:orderInCategory="1"
android:title="@string/logviewer_share"
app:iconTint="@color/material_on_surface_emphasis_medium"
app:showAsAction="ifRoom" />
</menu>

View File

@ -306,6 +306,11 @@
<string name="creator_avatar_description">Awatar</string>
<string name="creator_see_more">Zobacz więcej na GitHub</string>
<!--Log viewer-->
<string name="logviewer_title">Przeglądarka logów</string>
<string name="logviewer_share">Share logs</string>
<string name="logviewer_refresh">Odśwież</string>
<!--Generic-->
<string name="all_content">Treść</string>

View File

@ -295,6 +295,16 @@
<string name="license_dialog_title">Лицензия</string>
<!--Creators-->
<string name="creator_avatar_description">аватар</string>
<string name="creator_see_more">Смотрите больше на GitHub</string>
<!--Log viewer-->
<string name="logviewer_title">Просмотр журнала</string>
<string name="logviewer_share">Share logs</string>
<string name="logviewer_refresh">Обновление</string>
<!--Generic-->
<string name="all_content">Содержание</string>
<string name="all_retry">Снова</string>

View File

@ -12,6 +12,7 @@
<string name="settings_title">Settings</string>
<string name="more_title">More</string>
<string name="about_title">About</string>
<string name="logviewer_title">Log viewer</string>
<string name="creators_title">Creators</string>
<string name="license_title">Licenses</string>
<string name="message_title">Messages</string>
@ -287,6 +288,10 @@
<string name="creator_avatar_description">Avatar</string>
<string name="creator_see_more">See more on GitHub</string>
<!--Log viewer-->
<string name="logviewer_share">Share logs</string>
<string name="logviewer_refresh">Refresh</string>
<!--Generic-->
<string name="all_content">Content</string>

View File

@ -0,0 +1,5 @@
<paths>
<files-path
name="files"
path="." />
</paths>

View File

@ -1,10 +1,12 @@
package io.github.wulkanowy.utils
import android.content.Context
import android.util.Log
import com.crashlytics.android.Crashlytics
import com.crashlytics.android.core.CrashlyticsCore
import fr.bipi.tressence.crash.CrashlyticsLogExceptionTree
import fr.bipi.tressence.crash.CrashlyticsLogTree
import io.fabric.sdk.android.Fabric
import timber.log.Timber
fun initCrashlytics(context: Context, appInfo: AppInfo) {
Fabric.with(Fabric.Builder(context)
@ -19,13 +21,6 @@ fun initCrashlytics(context: Context, appInfo: AppInfo) {
.build())
}
class CrashlyticsTree : Timber.Tree() {
class CrashlyticsTree : CrashlyticsLogTree(Log.VERBOSE)
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
Crashlytics.setInt("priority", priority)
Crashlytics.setString("tag", tag)
if (t == null) Crashlytics.log(message)
else Crashlytics.logException(t)
}
}
class CrashlyticsExceptionTree : CrashlyticsLogExceptionTree()