|
| 1 | +package com.health.openscale.sync.core.utils |
| 2 | + |
| 3 | +import android.content.Context |
| 4 | +import android.content.SharedPreferences |
| 5 | +import android.os.Build |
| 6 | +import android.util.Log |
| 7 | +import timber.log.Timber |
| 8 | +import java.io.File |
| 9 | +import java.io.FileOutputStream |
| 10 | +import java.io.FileWriter |
| 11 | +import java.text.SimpleDateFormat |
| 12 | +import java.util.Date |
| 13 | +import java.util.Locale |
| 14 | + |
| 15 | +class FileLoggingTree( |
| 16 | + private val context: Context, |
| 17 | + private val maxSizeBytes: Long = 10L * 1024L * 1024L // 10 MB |
| 18 | +) : Timber.Tree() { |
| 19 | + |
| 20 | + companion object { |
| 21 | + const val BASE_NAME = "openscale_sync_log.txt" |
| 22 | + private val TS_HUMAN = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) |
| 23 | + } |
| 24 | + |
| 25 | + private val lock = Any() |
| 26 | + private val logDir: File by lazy { File(context.filesDir, "logs").apply { mkdirs() } } |
| 27 | + private val logFile: File by lazy { |
| 28 | + File(logDir, BASE_NAME).apply { |
| 29 | + if (!exists()) writeHeader(this) |
| 30 | + } |
| 31 | + } |
| 32 | + |
| 33 | + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { |
| 34 | + val ts = TS_HUMAN.format(Date()) |
| 35 | + val lvl = when (priority) { |
| 36 | + Log.VERBOSE -> "VERBOSE" |
| 37 | + Log.DEBUG -> "DEBUG" |
| 38 | + Log.INFO -> "INFO" |
| 39 | + Log.WARN -> "WARN" |
| 40 | + Log.ERROR -> "ERROR" |
| 41 | + else -> "UNKNOWN" |
| 42 | + } |
| 43 | + |
| 44 | + val sb = StringBuilder() |
| 45 | + .append(ts).append(' ') |
| 46 | + .append('[').append(lvl).append(']').append(' ') |
| 47 | + .append(tag ?: "openScale-sync").append(": ") |
| 48 | + .append(message).append('\n') |
| 49 | + |
| 50 | + if (t != null) sb.append(Log.getStackTraceString(t)).append('\n') |
| 51 | + |
| 52 | + val payload = sb.toString() |
| 53 | + val payloadBytes = payload.toByteArray(Charsets.UTF_8) |
| 54 | + |
| 55 | + synchronized(lock) { |
| 56 | + rotateIfNeeded(payloadBytes.size) |
| 57 | + runCatching { FileWriter(logFile, true).use { it.write(payload) } } |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + fun file(): File = logFile |
| 62 | + |
| 63 | + private fun rotateIfNeeded(incomingBytes: Int) { |
| 64 | + val currentBytes = if (logFile.exists()) logFile.length() else 0L |
| 65 | + if (currentBytes + incomingBytes <= maxSizeBytes) return |
| 66 | + |
| 67 | + val now = TS_HUMAN.format(Date()) |
| 68 | + val note = "NOTE: Previous log exceeded ${formatBytes(maxSizeBytes)} at $now; started new log.\n\n" |
| 69 | + |
| 70 | + if (logFile.exists()) runCatching { logFile.delete() } |
| 71 | + writeHeader(logFile) |
| 72 | + runCatching { FileWriter(logFile, true).use { it.write(note) } } |
| 73 | + } |
| 74 | + |
| 75 | + fun writeHeader(target: File) { |
| 76 | + val pm = context.packageManager |
| 77 | + val pkg = context.packageName |
| 78 | + val info = if (Build.VERSION.SDK_INT >= 33) { |
| 79 | + pm.getPackageInfo(pkg, android.content.pm.PackageManager.PackageInfoFlags.of(0)) |
| 80 | + } else { |
| 81 | + @Suppress("DEPRECATION") |
| 82 | + pm.getPackageInfo(pkg, 0) |
| 83 | + } |
| 84 | + val appName = context.applicationInfo.loadLabel(pm).toString() |
| 85 | + val versionName = info.versionName ?: "?" |
| 86 | + val versionCode = info.longVersionCode |
| 87 | + val started = TS_HUMAN.format(Date()) |
| 88 | + val device = "${Build.MANUFACTURER} ${Build.MODEL}" |
| 89 | + val androidVersion = "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})" |
| 90 | + |
| 91 | + val header = buildString { |
| 92 | + appendLine("============================================================") |
| 93 | + appendLine("openScale Sync Log") |
| 94 | + appendLine("Application : $appName") |
| 95 | + appendLine("Package : $pkg") |
| 96 | + appendLine("Version : $versionName ($versionCode)") |
| 97 | + appendLine("Device : $device") |
| 98 | + appendLine("OS : $androidVersion") |
| 99 | + appendLine("Log started : $started") |
| 100 | + appendLine("============================================================") |
| 101 | + appendLine() |
| 102 | + } |
| 103 | + FileWriter(target, true).use { it.write(header) } |
| 104 | + } |
| 105 | + |
| 106 | + private fun formatBytes(bytes: Long): String { |
| 107 | + val mb = bytes / (1024.0 * 1024.0) |
| 108 | + return String.format(Locale.US, "%.1f MB", mb) |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +object LogManager { |
| 113 | + private const val PREF_KEY = "loggingEnabled" |
| 114 | + private var fileTree: FileLoggingTree? = null |
| 115 | + private const val MAX_SIZE_BYTES: Long = 10L * 1024L * 1024L // 10 MB |
| 116 | + private val lock = Any() |
| 117 | + |
| 118 | + fun init(context: Context, prefs: SharedPreferences) { |
| 119 | + if (isEnabled(prefs)) enableInternal(context) else disableInternal() |
| 120 | + } |
| 121 | + |
| 122 | + fun isEnabled(prefs: SharedPreferences): Boolean = |
| 123 | + prefs.getBoolean(PREF_KEY, false) |
| 124 | + |
| 125 | + fun setEnabled(context: Context, prefs: SharedPreferences, enabled: Boolean) { |
| 126 | + synchronized(lock) { |
| 127 | + val wasEnabled = isEnabled(prefs) |
| 128 | + prefs.edit().putBoolean(PREF_KEY, enabled).apply() |
| 129 | + |
| 130 | + if (enabled && !wasEnabled) { |
| 131 | + // Transition off -> on: fresh file |
| 132 | + disableInternal() |
| 133 | + freshLogFile(context) |
| 134 | + enableInternal(context) |
| 135 | + Timber.i("Logging enabled (fresh start)") |
| 136 | + } else if (!enabled && wasEnabled) { |
| 137 | + // Transition on -> off |
| 138 | + disableInternal() |
| 139 | + Timber.i("Logging disabled") |
| 140 | + } else { |
| 141 | + } |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + private fun enableInternal(context: Context) { |
| 146 | + if (fileTree == null) { |
| 147 | + fileTree = FileLoggingTree(context.applicationContext, MAX_SIZE_BYTES) |
| 148 | + Timber.plant(fileTree!!) |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + private fun disableInternal() { |
| 153 | + fileTree?.let { |
| 154 | + Timber.uproot(it) |
| 155 | + fileTree = null |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + fun logFile(context: Context): File = |
| 160 | + (fileTree ?: FileLoggingTree(context, MAX_SIZE_BYTES)).file() |
| 161 | + |
| 162 | + fun hasLogFile(context: Context): Boolean { |
| 163 | + val f = logFile(context) |
| 164 | + return f.exists() && f.length() > 0 |
| 165 | + } |
| 166 | + |
| 167 | + private fun freshLogFile(context: Context) { |
| 168 | + val dir = File(context.filesDir, "logs").apply { mkdirs() } |
| 169 | + val f = File(dir, FileLoggingTree.BASE_NAME) |
| 170 | + if (f.exists()) runCatching { f.delete() } |
| 171 | + FileLoggingTree(context).writeHeader(target = f) |
| 172 | + } |
| 173 | + |
| 174 | + fun clearLog(context: Context) { |
| 175 | + synchronized(lock) { |
| 176 | + val dir = File(context.filesDir, "logs").apply { mkdirs() } |
| 177 | + val f = File(dir, FileLoggingTree.BASE_NAME) |
| 178 | + if (f.exists()) runCatching { f.delete() } |
| 179 | + FileLoggingTree(context).writeHeader(target = f) |
| 180 | + } |
| 181 | + } |
| 182 | +} |
0 commit comments