Logger for debug logs
This commit is contained in:
128
app/src/main/java/org/koitharu/kotatsu/core/logs/FileLogger.kt
Normal file
128
app/src/main/java/org/koitharu/kotatsu/core/logs/FileLogger.kt
Normal file
@@ -0,0 +1,128 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.utils.ext.subdir
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
private const val DIR = "logs"
|
||||
private const val FLUSH_DELAY = 2_000L
|
||||
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
|
||||
|
||||
class FileLogger(
|
||||
context: Context,
|
||||
private val settings: AppSettings,
|
||||
name: String,
|
||||
) {
|
||||
|
||||
val file by lazy {
|
||||
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
|
||||
File(dir, "$name.log")
|
||||
}
|
||||
val isEnabled: Boolean
|
||||
get() = settings.isLoggingEnabled
|
||||
private val dateFormat = SimpleDateFormat.getDateTimeInstance(
|
||||
SimpleDateFormat.SHORT,
|
||||
SimpleDateFormat.SHORT,
|
||||
Locale.ROOT,
|
||||
)
|
||||
private val buffer = ConcurrentLinkedQueue<String>()
|
||||
private val mutex = Mutex()
|
||||
private var flushJob: Job? = null
|
||||
|
||||
fun log(message: String, e: Throwable? = null) {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
val text = buildString {
|
||||
append(dateFormat.format(Date()))
|
||||
append(": ")
|
||||
if (e != null) {
|
||||
append("E!")
|
||||
}
|
||||
append(message)
|
||||
if (e != null) {
|
||||
append(' ')
|
||||
append(e.stackTraceToString())
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
buffer.add(text)
|
||||
postFlush()
|
||||
}
|
||||
|
||||
suspend fun flush() {
|
||||
if (!isEnabled) {
|
||||
return
|
||||
}
|
||||
flushJob?.cancelAndJoin()
|
||||
flushImpl()
|
||||
}
|
||||
|
||||
private fun postFlush() {
|
||||
if (flushJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
delay(FLUSH_DELAY)
|
||||
runCatchingCancellable {
|
||||
flushImpl()
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun flushImpl() {
|
||||
mutex.withLock {
|
||||
if (buffer.isEmpty()) {
|
||||
return
|
||||
}
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
if (file.length() > MAX_SIZE_BYTES) {
|
||||
rotate()
|
||||
}
|
||||
FileOutputStream(file, true).use {
|
||||
while (true) {
|
||||
val message = buffer.poll() ?: break
|
||||
it.write(message.toByteArray())
|
||||
it.write('\n'.code)
|
||||
}
|
||||
it.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun rotate() {
|
||||
val length = file.length()
|
||||
val bakFile = File(file.parentFile, file.name + ".bak")
|
||||
file.renameTo(bakFile)
|
||||
bakFile.inputStream().use { input ->
|
||||
input.skip(length - MAX_SIZE_BYTES / 2)
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
bakFile.delete()
|
||||
}
|
||||
}
|
||||
10
app/src/main/java/org/koitharu/kotatsu/core/logs/Loggers.kt
Normal file
10
app/src/main/java/org/koitharu/kotatsu/core/logs/Loggers.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class TrackerLogger
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.core.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.collection.arraySetOf
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.ElementsIntoSet
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object LoggersModule {
|
||||
|
||||
@Provides
|
||||
@TrackerLogger
|
||||
fun provideTrackerLogger(
|
||||
@ApplicationContext context: Context,
|
||||
settings: AppSettings,
|
||||
) = FileLogger(context, settings, "tracker")
|
||||
|
||||
@Provides
|
||||
@ElementsIntoSet
|
||||
fun provideAllLoggers(
|
||||
@TrackerLogger trackerLogger: FileLogger,
|
||||
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
|
||||
trackerLogger,
|
||||
)
|
||||
}
|
||||
@@ -159,6 +159,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
|
||||
|
||||
val isLoggingEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
|
||||
|
||||
var isBiometricProtectionEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
|
||||
@@ -371,6 +374,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_SHELF_SECTIONS = "shelf_sections_2"
|
||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||
const val KEY_APP_LOCALE = "app_locale"
|
||||
const val KEY_LOGGING_ENABLED = "logging"
|
||||
const val KEY_LOGS_SHARE = "logs_share"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
|
||||
@@ -38,7 +38,10 @@ class MainNavigationDelegate(
|
||||
}
|
||||
|
||||
override fun onNavigationItemReselected(item: MenuItem) {
|
||||
val fragment = fragmentManager.findFragmentByTag(TAG_PRIMARY) as? RecyclerViewOwner ?: return
|
||||
val fragment = fragmentManager.findFragmentByTag(TAG_PRIMARY)
|
||||
if (fragment == null || fragment !is RecyclerViewOwner || fragment.view == null) {
|
||||
return
|
||||
}
|
||||
val recyclerView = fragment.recyclerView
|
||||
recyclerView.smoothScrollToPosition(0)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ class SettingsActivity :
|
||||
startActivity(intent)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
@@ -132,6 +133,7 @@ class SettingsActivity :
|
||||
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
|
||||
intent.getSerializableExtra(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL,
|
||||
)
|
||||
|
||||
ACTION_MANAGE_SOURCES -> SourcesSettingsFragment()
|
||||
else -> SettingsHeadersFragment()
|
||||
}
|
||||
|
||||
@@ -7,16 +7,24 @@ import androidx.core.net.toUri
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.core.github.AppVersion
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
|
||||
private val viewModel by viewModels<AboutSettingsViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var loggers: Set<@JvmSuppressWildcards FileLogger>
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_about)
|
||||
findPreference<Preference>(AppSettings.KEY_APP_VERSION)?.run {
|
||||
@@ -39,10 +47,17 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
|
||||
viewModel.checkForUpdates()
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_APP_TRANSLATION -> {
|
||||
openLink(getString(R.string.url_weblate), preference.title)
|
||||
true
|
||||
}
|
||||
|
||||
AppSettings.KEY_LOGS_SHARE -> {
|
||||
ShareHelper(preference.context).shareLogs(loggers)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,34 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.map
|
||||
import androidx.work.*
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerParameters
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.core.logs.TrackerLogger
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -31,6 +51,7 @@ import org.koitharu.kotatsu.utils.ext.referer
|
||||
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.utils.ext.trySetForeground
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@HiltWorker
|
||||
class TrackWorker @AssistedInject constructor(
|
||||
@@ -39,6 +60,7 @@ class TrackWorker @AssistedInject constructor(
|
||||
private val coil: ImageLoader,
|
||||
private val settings: AppSettings,
|
||||
private val tracker: Tracker,
|
||||
@TrackerLogger private val logger: FileLogger,
|
||||
) : CoroutineWorker(context, workerParams) {
|
||||
|
||||
private val notificationManager by lazy {
|
||||
@@ -46,6 +68,20 @@ class TrackWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
logger.log("doWork()")
|
||||
try {
|
||||
return doWorkImpl()
|
||||
} catch (e: Throwable) {
|
||||
logger.log("fatal", e)
|
||||
throw e
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
logger.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doWorkImpl(): Result {
|
||||
if (!settings.isTrackerEnabled) {
|
||||
return Result.success(workDataOf(0, 0))
|
||||
}
|
||||
@@ -53,6 +89,7 @@ class TrackWorker @AssistedInject constructor(
|
||||
trySetForeground()
|
||||
}
|
||||
val tracks = tracker.getAllTracks()
|
||||
logger.log("Total ${tracks.size} tracks")
|
||||
if (tracks.isEmpty()) {
|
||||
return Result.success(workDataOf(0, 0))
|
||||
}
|
||||
@@ -70,6 +107,7 @@ class TrackWorker @AssistedInject constructor(
|
||||
success++
|
||||
}
|
||||
}
|
||||
logger.log("Result: success: $success, failed: $failed")
|
||||
val resultData = workDataOf(success, failed)
|
||||
return if (success == 0 && failed != 0) {
|
||||
Result.failure(resultData)
|
||||
@@ -85,6 +123,8 @@ class TrackWorker @AssistedInject constructor(
|
||||
async(dispatcher) {
|
||||
runCatchingCancellable {
|
||||
tracker.fetchUpdates(track, commit = true)
|
||||
}.onFailure {
|
||||
logger.log("checkUpdatesAsync", it)
|
||||
}.onSuccess { updates ->
|
||||
if (updates.isValid && updates.isNotEmpty()) {
|
||||
showNotification(
|
||||
|
||||
@@ -4,10 +4,11 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.logs.FileLogger
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.io.File
|
||||
|
||||
private const val TYPE_TEXT = "text/plain"
|
||||
private const val TYPE_IMAGE = "image/*"
|
||||
@@ -79,4 +80,15 @@ class ShareHelper(private val context: Context) {
|
||||
.setChooserTitle(R.string.share)
|
||||
.startChooser()
|
||||
}
|
||||
}
|
||||
|
||||
fun shareLogs(loggers: Collection<FileLogger>) {
|
||||
val intentBuilder = ShareCompat.IntentBuilder(context)
|
||||
.setType(TYPE_TEXT)
|
||||
for (logger in loggers) {
|
||||
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logger.file)
|
||||
intentBuilder.addStream(uri)
|
||||
}
|
||||
intentBuilder.setChooserTitle(R.string.share_logs)
|
||||
intentBuilder.startChooser()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user