mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
logging: add file-based logging and a Send logs option
Capture logs to rotating app-private files with Subsonic credential and URL basic-auth redaction, mirror to Logcat, and capture uncaught crashes. Log slow SQL (>100ms), SQL errors, migrations and DB lifecycle via a decorating SQLiteDriver. Add a Send logs row in Settings that zips the logs and opens the share sheet through a FileProvider.
This commit is contained in:
@@ -8,16 +8,23 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.ArtworkSeedStore
|
||||
import com.subtracks.di.appModule
|
||||
import com.subtracks.log.Log
|
||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
|
||||
class SubtracksApp : Application() {
|
||||
private val http = UserAgent.httpClient()
|
||||
private val http =
|
||||
UserAgent
|
||||
.httpClient()
|
||||
.newBuilder()
|
||||
.addInterceptor(Log.interceptor())
|
||||
.build()
|
||||
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.init(this)
|
||||
stopKoin()
|
||||
SingletonImageLoader.reset()
|
||||
val koin =
|
||||
|
||||
@@ -3,10 +3,14 @@ package com.subtracks.data.db
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import com.subtracks.log.Log
|
||||
|
||||
fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
||||
Room
|
||||
fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver(), Log::sql)
|
||||
return Room
|
||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.addMigrations(*MIGRATIONS)
|
||||
.setDriver(driver)
|
||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||
.addCallback(SqliteLifecycleLogging)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.SQLiteDriver
|
||||
import androidx.sqlite.SQLiteStatement
|
||||
import com.subtracks.log.Log
|
||||
|
||||
data class SqlExecution(
|
||||
val sql: String,
|
||||
val durationNanos: Long,
|
||||
val rows: Long,
|
||||
val error: Throwable?,
|
||||
) {
|
||||
val durationMillis: Double get() = durationNanos / 1_000_000.0
|
||||
}
|
||||
|
||||
class LoggingSQLiteDriver(
|
||||
private val delegate: SQLiteDriver,
|
||||
private val onExecution: (SqlExecution) -> Unit,
|
||||
) : SQLiteDriver {
|
||||
override val hasConnectionPool: Boolean
|
||||
get() = delegate.hasConnectionPool
|
||||
|
||||
override fun open(fileName: String): SQLiteConnection = LoggingConnection(delegate.open(fileName), onExecution)
|
||||
}
|
||||
|
||||
private class LoggingConnection(
|
||||
private val delegate: SQLiteConnection,
|
||||
private val onExecution: (SqlExecution) -> Unit,
|
||||
) : SQLiteConnection by delegate {
|
||||
override fun prepare(sql: String): SQLiteStatement {
|
||||
val start = System.nanoTime()
|
||||
return try {
|
||||
LoggingStatement(delegate.prepare(sql), sql, onExecution)
|
||||
} catch (failure: Throwable) {
|
||||
onExecution(SqlExecution(sql, System.nanoTime() - start, 0, failure))
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoggingStatement(
|
||||
private val delegate: SQLiteStatement,
|
||||
private val sql: String,
|
||||
private val onExecution: (SqlExecution) -> Unit,
|
||||
) : SQLiteStatement by delegate {
|
||||
private var startNanos = System.nanoTime()
|
||||
private var rows = 0L
|
||||
private var failure: Throwable? = null
|
||||
private var started = false
|
||||
private var reported = false
|
||||
|
||||
override fun step(): Boolean {
|
||||
started = true
|
||||
val result =
|
||||
try {
|
||||
delegate.step()
|
||||
} catch (error: Throwable) {
|
||||
failure = error
|
||||
report()
|
||||
throw error
|
||||
}
|
||||
if (result) rows++
|
||||
if (!result) report()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
report()
|
||||
delegate.reset()
|
||||
begin()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
report()
|
||||
delegate.close()
|
||||
}
|
||||
|
||||
private fun report() {
|
||||
if (!started || reported) return
|
||||
reported = true
|
||||
onExecution(SqlExecution(sql, System.nanoTime() - startNanos, rows, failure))
|
||||
}
|
||||
|
||||
private fun begin() {
|
||||
startNanos = System.nanoTime()
|
||||
rows = 0
|
||||
failure = null
|
||||
started = false
|
||||
reported = false
|
||||
}
|
||||
}
|
||||
|
||||
class LoggedMigration(
|
||||
private val delegate: Migration,
|
||||
) : Migration(delegate.startVersion, delegate.endVersion) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
val label = "${delegate.startVersion} -> ${delegate.endVersion}"
|
||||
Log.i("sql", "migrating $label")
|
||||
val start = System.nanoTime()
|
||||
try {
|
||||
delegate.migrate(connection)
|
||||
} catch (failure: Throwable) {
|
||||
Log.e("sql", "migration $label failed after ${(System.nanoTime() - start) / 1_000_000}ms", failure)
|
||||
throw failure
|
||||
}
|
||||
Log.i("sql", "migrated $label in ${(System.nanoTime() - start) / 1_000_000}ms")
|
||||
}
|
||||
}
|
||||
|
||||
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
|
||||
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||
Log.i("sql", "created fresh database")
|
||||
}
|
||||
|
||||
override suspend fun onOpen(connection: SQLiteConnection) {
|
||||
Log.i("sql", "database opened")
|
||||
}
|
||||
|
||||
override suspend fun onDestructiveMigration(connection: SQLiteConnection) {
|
||||
Log.w("sql", "destructive migration: dropping all tables")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.util.Log
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
@@ -8,6 +7,7 @@ import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.subsonic.SubsonicException
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -68,7 +68,7 @@ class SyncManager(
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
Log.w(TAG, "Sync failed", failure)
|
||||
Log.w("sync", "sync failed", failure)
|
||||
val message =
|
||||
when (failure) {
|
||||
is NoServerException -> {
|
||||
@@ -86,10 +86,6 @@ class SyncManager(
|
||||
queueRepository.invalidateLibraryCache()
|
||||
_status.value = result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SubtracksSync"
|
||||
}
|
||||
}
|
||||
|
||||
private class NoServerException : IllegalStateException()
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.subtracks.log
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import com.subtracks.BuildConfig
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.db.SqlExecution
|
||||
import okhttp3.Interceptor
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToLong
|
||||
import android.util.Log as AndroidLog
|
||||
|
||||
object Log {
|
||||
private const val SLOW_QUERY_MS = 100.0
|
||||
private const val MAX_SQL_CHARS = 500
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
private var fileStore: LogFileStore? = null
|
||||
private var appContext: Context? = null
|
||||
private var consoleLevel = LogLevel.DEBUG
|
||||
private var crashHandlerInstalled = false
|
||||
|
||||
fun init(context: Context) {
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
consoleLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
|
||||
val fileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
||||
fileStore = LogFileStore(File(app.filesDir, "logs"), fileLevel)
|
||||
installCrashHandler()
|
||||
i("app", "started ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) on Android ${Build.VERSION.SDK_INT} ${Build.MODEL}")
|
||||
}
|
||||
|
||||
fun v(
|
||||
tag: String,
|
||||
message: String,
|
||||
) = log(LogLevel.VERBOSE, tag, message, null)
|
||||
|
||||
fun d(
|
||||
tag: String,
|
||||
message: String,
|
||||
) = log(LogLevel.DEBUG, tag, message, null)
|
||||
|
||||
fun i(
|
||||
tag: String,
|
||||
message: String,
|
||||
) = log(LogLevel.INFO, tag, message, null)
|
||||
|
||||
fun w(
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable? = null,
|
||||
) = log(LogLevel.WARN, tag, message, throwable)
|
||||
|
||||
fun e(
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable? = null,
|
||||
) = log(LogLevel.ERROR, tag, message, throwable)
|
||||
|
||||
fun files(): List<File> = fileStore?.files().orEmpty()
|
||||
|
||||
fun exportZip(): File? {
|
||||
val store = fileStore ?: return null
|
||||
val context = appContext ?: return null
|
||||
store.flush()
|
||||
return store.exportZip(File(context.cacheDir, "log_dumps"))
|
||||
}
|
||||
|
||||
fun share(
|
||||
context: Context,
|
||||
file: File,
|
||||
) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val send =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/zip"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.settings_about_share_logs_subject))
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(send, context.getString(R.string.settings_about_actions_share_logs)),
|
||||
)
|
||||
}
|
||||
|
||||
fun interceptor(): Interceptor =
|
||||
Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
d("http", "${request.method} ${request.url}")
|
||||
try {
|
||||
val response = chain.proceed(request)
|
||||
if (response.code >= 400) {
|
||||
w("http", "${request.method} ${request.url} -> ${response.code}")
|
||||
}
|
||||
response
|
||||
} catch (failure: IOException) {
|
||||
w("http", "${request.method} ${request.url} failed", failure)
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
|
||||
fun sql(execution: SqlExecution) {
|
||||
val failure = execution.error
|
||||
if (failure != null) {
|
||||
e("sql", "failed: ${shortSql(execution.sql)}", failure)
|
||||
return
|
||||
}
|
||||
if (!isSlowQuery(execution, SLOW_QUERY_MS)) return
|
||||
w("sql", "slow ${execution.durationMillis.roundToLong()}ms rows=${execution.rows}: ${shortSql(execution.sql)}")
|
||||
}
|
||||
|
||||
private fun log(
|
||||
level: LogLevel,
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable?,
|
||||
) {
|
||||
val redacted = redactLogs(message)
|
||||
if (level.ordinal >= consoleLevel.ordinal) {
|
||||
val text = if (throwable != null) "$redacted\n${AndroidLog.getStackTraceString(throwable)}" else redacted
|
||||
AndroidLog.println(priority(level), tag.take(23), text)
|
||||
}
|
||||
fileStore?.write(level, tag, redacted, throwable)
|
||||
}
|
||||
|
||||
private fun priority(level: LogLevel): Int =
|
||||
when (level) {
|
||||
LogLevel.VERBOSE -> AndroidLog.VERBOSE
|
||||
LogLevel.DEBUG -> AndroidLog.DEBUG
|
||||
LogLevel.INFO -> AndroidLog.INFO
|
||||
LogLevel.WARN -> AndroidLog.WARN
|
||||
LogLevel.ERROR -> AndroidLog.ERROR
|
||||
}
|
||||
|
||||
private fun installCrashHandler() {
|
||||
if (crashHandlerInstalled) return
|
||||
crashHandlerInstalled = true
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching {
|
||||
fileStore?.writeImmediately(LogLevel.ERROR, "crash", "uncaught exception on ${thread.name}", throwable)
|
||||
}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortSql(sql: String): String = sql.replace(WHITESPACE, " ").take(MAX_SQL_CHARS)
|
||||
}
|
||||
|
||||
private val DATA_KEYWORDS = setOf("SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "REPLACE")
|
||||
private const val SQL_INTERNAL = "room_table_modification_log"
|
||||
|
||||
internal fun isDataStatement(sql: String): Boolean {
|
||||
val normalized = sql.trimStart()
|
||||
if (normalized.contains(SQL_INTERNAL)) return false
|
||||
return normalized.takeWhile { !it.isWhitespace() }.uppercase() in DATA_KEYWORDS
|
||||
}
|
||||
|
||||
internal fun isSlowQuery(
|
||||
execution: SqlExecution,
|
||||
thresholdMs: Double,
|
||||
): Boolean = execution.error == null && execution.durationMillis >= thresholdMs && isDataStatement(execution.sql)
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.subtracks.log
|
||||
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR }
|
||||
|
||||
fun interface LogSink {
|
||||
fun log(
|
||||
level: LogLevel,
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable?,
|
||||
)
|
||||
}
|
||||
|
||||
class LogFileStore(
|
||||
private val rootDir: File,
|
||||
private val minLevel: LogLevel,
|
||||
private val maxFileBytes: Long = DEFAULT_MAX_FILE_BYTES,
|
||||
private val maxFiles: Int = DEFAULT_MAX_FILES,
|
||||
) {
|
||||
private val executor =
|
||||
Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "log-writer").apply { isDaemon = true }
|
||||
}
|
||||
private val lock = Any()
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val stampFormat = SimpleDateFormat("yyyy-MM-dd-HHmmss-SSS", Locale.US)
|
||||
private val lineFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||
private var current = currentFile()
|
||||
|
||||
fun write(
|
||||
level: LogLevel,
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable?,
|
||||
) {
|
||||
if (level.ordinal < minLevel.ordinal) return
|
||||
executor.execute { synchronized(lock) { append(level, tag, message, throwable) } }
|
||||
}
|
||||
|
||||
fun writeImmediately(
|
||||
level: LogLevel,
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable?,
|
||||
) {
|
||||
if (level.ordinal < minLevel.ordinal) return
|
||||
synchronized(lock) { append(level, tag, message, throwable) }
|
||||
}
|
||||
|
||||
fun flush() {
|
||||
executor.submit {}.get()
|
||||
}
|
||||
|
||||
fun files(): List<File> =
|
||||
rootDir
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(LOG_SUFFIX) }
|
||||
?.sortedByDescending { it.lastModified() }
|
||||
.orEmpty()
|
||||
|
||||
fun exportZip(destDir: File): File? {
|
||||
val logs = files()
|
||||
if (logs.isEmpty()) return null
|
||||
destDir.mkdirs()
|
||||
val zip = File(destDir, "subtracks-logs-${stampFormat.format(Date())}.zip")
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { out ->
|
||||
logs.forEach { log ->
|
||||
out.putNextEntry(ZipEntry(log.name))
|
||||
log.inputStream().use { it.copyTo(out) }
|
||||
out.closeEntry()
|
||||
}
|
||||
}
|
||||
return zip
|
||||
}
|
||||
|
||||
private fun append(
|
||||
level: LogLevel,
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable?,
|
||||
) {
|
||||
val text =
|
||||
buildString {
|
||||
append(lineFormat.format(Date()))
|
||||
append(' ')
|
||||
append(LEVEL_CHARS[level.ordinal])
|
||||
append(' ')
|
||||
append(tag)
|
||||
append(' ')
|
||||
append(message)
|
||||
throwable?.let {
|
||||
append('\n')
|
||||
append(it.stackTraceToString().trimEnd())
|
||||
}
|
||||
append('\n')
|
||||
}
|
||||
rootDir.mkdirs()
|
||||
if (current.length() >= maxFileBytes) roll()
|
||||
current.appendText(redactLogs(text))
|
||||
prune()
|
||||
}
|
||||
|
||||
private var rollSequence = 0
|
||||
|
||||
private fun roll() {
|
||||
if (current.exists()) {
|
||||
current.renameTo(uniqueRolledFile())
|
||||
}
|
||||
current = currentFile()
|
||||
}
|
||||
|
||||
private fun uniqueRolledFile(): File {
|
||||
val day = dateFormat.format(Date())
|
||||
while (true) {
|
||||
val candidate = File(rootDir, "$day-${rollSequence++}$LOG_SUFFIX")
|
||||
if (!candidate.exists()) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
private fun prune() {
|
||||
val logs = files()
|
||||
if (logs.size > maxFiles) logs.drop(maxFiles).forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun currentFile(): File = File(rootDir, dateFormat.format(Date()) + LOG_SUFFIX)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_MAX_FILE_BYTES = 1L * 1024 * 1024
|
||||
const val DEFAULT_MAX_FILES = 7
|
||||
private const val LOG_SUFFIX = ".log"
|
||||
private val LEVEL_CHARS = charArrayOf('V', 'D', 'I', 'W', 'E')
|
||||
}
|
||||
}
|
||||
|
||||
private val URL_USERINFO = Regex("([a-zA-Z][a-zA-Z0-9+.\\-]*://)[^/@\\s]+@")
|
||||
private val URL_PARAM = Regex("([?&](?:u|p|s|t)=)[^&\\s]+")
|
||||
|
||||
internal fun redactLogs(input: String): String = input.replace(URL_USERINFO, "$1REDACTED@").replace(URL_PARAM, "$1REDACTED")
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MediaItem
|
||||
@@ -16,6 +15,7 @@ import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.log.Log
|
||||
|
||||
class Media3PlayerHandle(
|
||||
private val controller: MediaController,
|
||||
@@ -112,13 +112,13 @@ class Media3PlayerHandle(
|
||||
) = controller.replaceMediaItem(index, toMediaItem(item))
|
||||
|
||||
override fun seekToIndex(index: Int) {
|
||||
Log.d(TAG, "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
|
||||
Log.d("playback", "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
|
||||
controller.seekTo(index, 0)
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"playback",
|
||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
||||
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||
)
|
||||
@@ -136,7 +136,7 @@ class Media3PlayerHandle(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
||||
Log.d("playback", "item=${mediaItem?.mediaId}")
|
||||
listener.onTransition()
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ class Media3PlayerHandle(
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"playback",
|
||||
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
||||
error,
|
||||
)
|
||||
@@ -156,7 +156,7 @@ class Media3PlayerHandle(
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"playback",
|
||||
"state=$playbackState position=${controller.currentPosition} " +
|
||||
"playerDuration=${controller.duration} itemDuration=${currentItem?.durationMs}",
|
||||
)
|
||||
@@ -190,10 +190,6 @@ class Media3PlayerHandle(
|
||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||
?.getLong(EXTRA_DURATION_MS),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SubtracksPlayback"
|
||||
}
|
||||
}
|
||||
|
||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
@@ -33,6 +32,7 @@ import com.subtracks.UserAgent
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -186,12 +186,13 @@ class PlaybackService : MediaSessionService() {
|
||||
private fun streamingClient(): OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.addInterceptor(Log.interceptor())
|
||||
.addInterceptor { chain ->
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val response = chain.proceed(chain.request())
|
||||
Log.d(
|
||||
STREAM_TAG,
|
||||
"stream range=${chain.request().header("Range")} code=${response.code} " +
|
||||
"stream",
|
||||
"range=${chain.request().header("Range")} code=${response.code} " +
|
||||
"contentRange=${response.header("Content-Range")} " +
|
||||
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
||||
)
|
||||
@@ -211,7 +212,6 @@ class PlaybackService : MediaSessionService() {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STREAM_TAG = "SubtracksPlayback"
|
||||
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.ui.settings
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -27,6 +28,7 @@ import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Favorite
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Language
|
||||
import androidx.compose.material.icons.rounded.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -47,11 +49,13 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -62,10 +66,14 @@ import com.subtracks.BuildConfig
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.log.Log
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
||||
@@ -95,6 +103,8 @@ fun SettingsRoute(
|
||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
SettingsScreen(
|
||||
sources = sources,
|
||||
activeSourceId = activeSourceId,
|
||||
@@ -117,6 +127,16 @@ fun SettingsRoute(
|
||||
onAddServer = onAddServer,
|
||||
onOpenDownloads = onOpenDownloads,
|
||||
onOpenLicenses = onOpenLicenses,
|
||||
onShareLogs = {
|
||||
scope.launch {
|
||||
val zip = withContext(Dispatchers.IO) { Log.exportZip() }
|
||||
if (zip != null) {
|
||||
Log.share(context, zip)
|
||||
} else {
|
||||
Toast.makeText(context, R.string.settings_about_share_logs_empty, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
onBack = onBack,
|
||||
dismissals = dismissals,
|
||||
)
|
||||
@@ -147,6 +167,7 @@ fun SettingsScreen(
|
||||
onAddServer: () -> Unit,
|
||||
onOpenDownloads: () -> Unit,
|
||||
onOpenLicenses: () -> Unit,
|
||||
onShareLogs: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
dismissals: Flow<Unit> = emptyFlow(),
|
||||
) {
|
||||
@@ -364,6 +385,15 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_about_actions_share_logs)) },
|
||||
supportingContent = { Text(stringResource(R.string.settings_about_actions_share_logs_description)) },
|
||||
leadingContent = { Icon(Icons.Rounded.Share, contentDescription = null) },
|
||||
modifier = Modifier.clickable(onClick = onShareLogs),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(fill)) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user