feat(android): t+1 file log system with firebase storage (#11945)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced file-based logging that saves daily log files within the app.
  - Old log files are now automatically uploaded to cloud storage for backup.
  - Enhanced logging initialization to include file-based logging alongside crash reporting.
- **Chores**
  - Added Firebase Storage as a new dependency to support log file uploads.
- **Style**
  - Cleaned up unused imports in the authentication plugin.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
aki-chang-dev
2025-04-24 09:33:05 +00:00
parent 45df4568a4
commit b7e1812893
5 changed files with 129 additions and 4 deletions

View File

@@ -124,6 +124,7 @@ dependencies {
implementation platform(libs.firebase.bom)
implementation libs.firebase.analytics
implementation libs.firebase.crashlytics
implementation libs.firebase.storage
implementation platform(libs.okhttp.bom)
implementation libs.okhttp

View File

@@ -8,6 +8,7 @@ import app.affine.pro.utils.dataStore
import app.affine.pro.utils.get
import app.affine.pro.utils.logger.AffineDebugTree
import app.affine.pro.utils.logger.CrashlyticsTree
import app.affine.pro.utils.logger.FileTree
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.crashlytics.setCustomKeys
import com.google.firebase.ktx.Firebase
@@ -26,7 +27,12 @@ class AffineApp : Application() {
super.onCreate()
_context = applicationContext
// init logger
Timber.plant(if (BuildConfig.DEBUG) AffineDebugTree() else CrashlyticsTree())
if (BuildConfig.DEBUG) {
Timber.plant(AffineDebugTree())
} else {
Timber.plant(CrashlyticsTree(), FileTree(applicationContext))
}
Timber.i("Application started.")
// init capacitor config
CapacitorConfig.init(baseContext)
// init crashlytics
@@ -50,6 +56,7 @@ class AffineApp : Application() {
?: error("Parse user id cookie fail:[ cookie = $userIdCookieStr ]"),
)
CookieStore.saveCookies(BuildConfig.BASE_URL.toHttpUrl().host, cookies)
FileTree.get()?.checkAndUploadOldLogs()
} catch (e: Exception) {
Timber.w(e, "[init] load persistent cookies fail.")
}

View File

@@ -1,12 +1,9 @@
package app.affine.pro.plugin
import android.annotation.SuppressLint
import app.affine.pro.AffineApp
import app.affine.pro.CapacitorConfig
import app.affine.pro.service.CookieStore
import app.affine.pro.service.OkHttp
import app.affine.pro.utils.clear
import app.affine.pro.utils.dataStore
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall

View File

@@ -0,0 +1,119 @@
package app.affine.pro.utils.logger
import android.content.Context
import android.net.Uri
import android.util.Log
import app.affine.pro.BuildConfig
import app.affine.pro.service.CookieStore
import com.google.firebase.ktx.Firebase
import com.google.firebase.storage.ktx.storage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class FileTree(context: Context) : Timber.Tree() {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
private val logDirectory: File = File(context.filesDir, "logs")
private val currentLogFile: File
init {
if (!logDirectory.exists()) {
logDirectory.mkdirs()
}
val today = dateFormat.format(Date())
currentLogFile = File(logDirectory, "$today.log")
if (!currentLogFile.exists()) {
try {
currentLogFile.createNewFile()
} catch (e: IOException) {
Timber.e(e, "Create log file fail.")
}
}
}
suspend fun checkAndUploadOldLogs() {
val today = dateFormat.format(Date())
logDirectory.listFiles()?.forEach { file ->
val fileName = file.name
if (fileName.endsWith(".log") && !fileName.startsWith(today)) {
uploadLogToFirebase(file)
}
}
}
private suspend fun uploadLogToFirebase(file: File) =
suspendCancellableCoroutine { continuation ->
val user = CookieStore.getCookie(BuildConfig.BASE_URL, CookieStore.AFFINE_USER_ID)
?: return@suspendCancellableCoroutine
val storageRef = Firebase.storage.reference
val logFileRef = storageRef.child("android_log/$user/${file.name}")
val uploadTask = logFileRef.putFile(Uri.fromFile(file))
uploadTask.addOnSuccessListener {
if (file.delete()) {
if (continuation.isActive) continuation.resume(true) { _, _, _ -> }
} else {
if (continuation.isActive) continuation.resume(false) { _, _, _ -> }
}
}.addOnFailureListener { e ->
if (continuation.isActive) continuation.resume(false) {}
}
continuation.invokeOnCancellation {
if (uploadTask.isInProgress) {
uploadTask.cancel()
}
}
}
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (priority < Log.INFO) {
return
}
val level = when (priority) {
Log.ASSERT -> "[assert]"
Log.ERROR -> "[error]"
Log.WARN -> "[warn]"
else -> "[info]"
}
val log = StringBuilder(level)
.append(tag?.let { "[$tag]" } ?: "")
.append(" ")
.append(message)
.toString()
MainScope().launch {
try {
val timestamp =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date())
val logMessage = "[$timestamp] $log\n"
withContext(Dispatchers.IO) {
FileOutputStream(currentLogFile, true).use {
it.write(logMessage.toByteArray())
t?.stackTraceToString()?.let { stacktrace ->
it.write(stacktrace.toByteArray())
}
}
}
} catch (e: IOException) {
Timber.e(e, "Failed to write to log file")
}
}
}
companion object {
fun get() = Timber.forest().filterIsInstance<FileTree>().firstOrNull()
}
}