diff --git a/app/src/main/java/org/koitharu/kotatsu/core/logs/FileLogger.kt b/app/src/main/java/org/koitharu/kotatsu/core/logs/FileLogger.kt new file mode 100644 index 000000000..3f92e1988 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/logs/FileLogger.kt @@ -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() + 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() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/logs/Loggers.kt b/app/src/main/java/org/koitharu/kotatsu/core/logs/Loggers.kt new file mode 100644 index 000000000..1c26af372 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/logs/Loggers.kt @@ -0,0 +1,10 @@ +package org.koitharu.kotatsu.core.logs + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class TrackerLogger + + + diff --git a/app/src/main/java/org/koitharu/kotatsu/core/logs/LoggersModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/logs/LoggersModule.kt new file mode 100644 index 000000000..547e874a0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/logs/LoggersModule.kt @@ -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, + ) +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 396d9157f..132080b34 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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" diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt index cf1e35323..dc3393a90 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt @@ -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) } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt index f00be0080..c648d9626 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -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() } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt index 199cc513e..aabd8a95f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt @@ -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() + @Inject + lateinit var loggers: Set<@JvmSuppressWildcards FileLogger> + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_about) findPreference(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) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index bf20710cc..b4575a033 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -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( diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt index cdd15ab86..c1923c182 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt @@ -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() } -} \ No newline at end of file + + fun shareLogs(loggers: Collection) { + 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() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6ff653519..3e7c61a34 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -402,4 +402,7 @@ Content preloading Mark as current Language + Share logs + Enable logging + Record some actions for debug purposes diff --git a/app/src/main/res/xml/pref_about.xml b/app/src/main/res/xml/pref_about.xml index a4e72c089..bb7c023fb 100644 --- a/app/src/main/res/xml/pref_about.xml +++ b/app/src/main/res/xml/pref_about.xml @@ -1,6 +1,7 @@ + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> @@ -9,10 +10,22 @@ android:persistent="false" android:summary="@string/check_for_updates" /> + + + + + android:title="@string/about_app_translation" + app:allowDividerAbove="true" />