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
22 changed files with 361 additions and 27 deletions

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