diff --git a/packages/frontend/apps/android/App/app/build.gradle b/packages/frontend/apps/android/App/app/build.gradle index 38a9f0a26..5c1fca8b1 100644 --- a/packages/frontend/apps/android/App/app/build.gradle +++ b/packages/frontend/apps/android/App/app/build.gradle @@ -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 diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineApp.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineApp.kt index 9961908d6..56a9e8763 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineApp.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineApp.kt @@ -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.") } diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt index 040c8f6ef..6d978ef09 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt @@ -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 diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/logger/FileTree.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/logger/FileTree.kt new file mode 100644 index 000000000..5e124ce93 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/utils/logger/FileTree.kt @@ -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().firstOrNull() + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/gradle/libs.versions.toml b/packages/frontend/apps/android/App/gradle/libs.versions.toml index 420ad292a..28624028f 100644 --- a/packages/frontend/apps/android/App/gradle/libs.versions.toml +++ b/packages/frontend/apps/android/App/gradle/libs.versions.toml @@ -73,6 +73,7 @@ apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.r firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } +firebase-storage = { module = "com.google.firebase:firebase-storage" } google-services = { module = "com.google.gms:google-services", version.ref = "google-services" } hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }