chore(android): integrate firebase-crashlytics (#11808)

This commit is contained in:
Aki Chang
2025-04-21 10:37:31 +08:00
committed by GitHub
parent f82f213373
commit e3973538e8
20 changed files with 334 additions and 158 deletions
@@ -1,7 +1,22 @@
package app.affine.pro
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import app.affine.pro.service.CookieStore
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 com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.crashlytics.setCustomKeys
import com.google.firebase.ktx.Firebase
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber
@HiltAndroidApp
@@ -9,8 +24,47 @@ class AffineApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
_context = applicationContext
// init logger
Timber.plant(if (BuildConfig.DEBUG) AffineDebugTree() else CrashlyticsTree())
// init capacitor config
CapacitorConfig.init(baseContext)
// init crashlytics
Firebase.crashlytics.setCustomKeys {
key("affine_version", CapacitorConfig.getAffineVersion())
}
// init cookies from local
MainScope().launch(Dispatchers.IO) {
val sessionCookieStr = applicationContext.dataStore.get(CookieStore.AFFINE_SESSION)
val userIdCookieStr = applicationContext.dataStore.get(CookieStore.AFFINE_USER_ID)
if (sessionCookieStr.isEmpty() || userIdCookieStr.isEmpty()) {
Timber.i("[init] user has not signed in yet.")
return@launch
}
Timber.i("[init] user already signed in.")
try {
val cookies = listOf(
Cookie.parse(BuildConfig.BASE_URL.toHttpUrl(), sessionCookieStr)
?: error("Parse session cookie fail:[ cookie = $sessionCookieStr ]"),
Cookie.parse(BuildConfig.BASE_URL.toHttpUrl(), userIdCookieStr)
?: error("Parse user id cookie fail:[ cookie = $userIdCookieStr ]"),
)
CookieStore.saveCookies(BuildConfig.BASE_URL.toHttpUrl().host, cookies)
} catch (e: Exception) {
Timber.w(e, "[init] load persistent cookies fail.")
}
}
}
override fun onTerminate() {
_context = null
super.onTerminate()
}
companion object {
@SuppressLint("StaticFieldLeak")
private var _context: Context? = null
fun context() = requireNotNull(_context)
}
}
@@ -1,4 +1,4 @@
package app.affine.pro.ai
package app.affine.pro
enum class Prompt(val value: String) {
Summary("Summary"),
@@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
@HiltViewModel
class ChatViewModel @Inject constructor(
@@ -37,10 +36,10 @@ class ChatViewModel @Inject constructor(
workspaceId = webRepo.workspaceId(),
docId = webRepo.docId(),
).getOrElse {
Timber.d("Create session failed")
Timber.w(it, "Create session failed")
return@launch
}
Timber.d("Create session: $sessionId")
Timber.i("Create session success:[ sessionId = $sessionId].")
val historyMessages = graphQLRepo.getCopilotHistories(
workspaceId = webRepo.workspaceId(),
docId = webRepo.docId(),
@@ -60,16 +59,12 @@ class ChatViewModel @Inject constructor(
sessionId = sessionId,
message = message,
).onSuccess { messageId ->
Timber.d("send message: $messageId")
Timber.i("send message: $messageId")
sseRepo.messageStream(sessionId, messageId)
.onEach {
Timber.d("$coroutineContext")
Timber.d("on message: ${it.getOrNull()}")
Timber.d("On sse message: ${it.getOrNull()}")
}
.flowOn(Dispatchers.IO)
.onEach {
Timber.d("$coroutineContext")
}
.collect()
}
}
@@ -80,7 +75,7 @@ class ChatViewModel @Inject constructor(
docId = webRepo.docId(),
).onSuccess { id ->
sessionId = id
Timber.d("Create session: $id")
Timber.i("Create session: $id")
sendMessage()
}.onFailure {
Timber.e(it, "Create session failed.")
@@ -4,6 +4,7 @@ import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import timber.log.Timber
@CapacitorPlugin(name = "AIButton")
class AIButtonPlugin : Plugin() {
@@ -16,6 +17,7 @@ class AIButtonPlugin : Plugin() {
@PluginMethod
fun present(call: PluginCall) {
launch {
Timber.i("present AIButton")
(activity as? Callback)?.present()
call.resolve()
}
@@ -24,6 +26,7 @@ class AIButtonPlugin : Plugin() {
@PluginMethod
fun dismiss(call: PluginCall) {
launch {
Timber.i("dismiss AIButton")
(activity as? Callback)?.dismiss()
call.resolve()
}
@@ -4,6 +4,7 @@ import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import timber.log.Timber
@CapacitorPlugin(name = "AffineTheme")
class AffineThemePlugin : Plugin() {
@@ -14,7 +15,9 @@ class AffineThemePlugin : Plugin() {
@PluginMethod
fun onThemeChanged(call: PluginCall) {
(bridge.activity as? Callback)?.onThemeChanged(call.data.optBoolean("darkMode"))
val darkMode = call.data.optBoolean("darkMode")
Timber.i("onThemeChanged:[ darkMode = $darkMode ]")
(bridge.activity as? Callback)?.onThemeChanged(darkMode)
call.resolve()
}
}
@@ -1,9 +1,12 @@
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
@@ -16,6 +19,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
import org.json.JSONObject
import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class)
@CapacitorPlugin(name = "Auth")
@@ -23,120 +27,18 @@ class AuthPlugin : Plugin() {
@PluginMethod
fun signInMagicLink(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
val email = call.getStringEnsure("email")
val token = call.getStringEnsure("token")
val clientNonce = call.getString("clientNonce")
val body = JSONObject()
.apply {
put("email", email)
put("token", token)
put("client_nonce", clientNonce)
}
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
val request = Request.Builder()
.url("$endpoint/api/auth/magic-link")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.post(body)
.build()
OkHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) {
call.reject(response.body.string())
return@launch
}
CookieStore.getCookie(endpoint, "affine_session")?.let {
call.resolve(JSObject().put("token", it))
} ?: call.reject("token not found")
}
} catch (e: Exception) {
call.reject("Failed to sign in, $e", null, e)
}
}
processSignIn(call, SignInMethod.MagicLink)
}
@PluginMethod
fun signInOauth(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
val code = call.getStringEnsure("code")
val state = call.getStringEnsure("state")
val clientNonce = call.getString("clientNonce")
val body = JSONObject()
.apply {
put("code", code)
put("state", state)
put("client_nonce", clientNonce)
}
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
val request = Request.Builder()
.url("$endpoint/api/oauth/callback")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.post(body)
.build()
OkHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) {
call.reject(response.body.string())
return@launch
}
CookieStore.getCookie(endpoint, "affine_session")?.let {
call.resolve(JSObject().put("token", it))
} ?: call.reject("token not found")
}
} catch (e: Exception) {
call.reject("Failed to sign in, $e", null, e)
}
}
processSignIn(call, SignInMethod.Oauth)
}
@SuppressLint("BuildListAdds")
@PluginMethod
fun signInPassword(call: PluginCall) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
val email = call.getStringEnsure("email")
val password = call.getStringEnsure("password")
val verifyToken = call.getString("verifyToken")
val challenge = call.getString("challenge")
val body = JSONObject()
.apply {
put("email", email)
put("password", password)
}
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
val requestBuilder = Request.Builder()
.url("$endpoint/api/auth/sign-in")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.post(body)
if (verifyToken != null) {
requestBuilder.addHeader("x-captcha-token", verifyToken)
}
if (challenge != null) {
requestBuilder.addHeader("x-captcha-challenge", challenge)
}
OkHttp.client.newCall(requestBuilder.build()).executeAsync().use { response ->
if (response.code >= 400) {
call.reject(response.body.string())
return@launch
}
CookieStore.getCookie(endpoint, "affine_session")?.let {
call.resolve(JSObject().put("token", it))
} ?: call.reject("token not found")
}
} catch (e: Exception) {
call.reject("Failed to sign in, $e", null, e)
}
}
processSignIn(call, SignInMethod.Password)
}
@PluginMethod
@@ -154,11 +56,110 @@ class AuthPlugin : Plugin() {
call.reject(response.body.string())
return@launch
}
Timber.i("Sign out success.")
call.resolve(JSObject().put("ok", true))
}
} catch (e: Exception) {
Timber.w(e, "Sign out fail.")
call.reject("Failed to sign out, $e", null, e)
}
}
}
private enum class SignInMethod {
Password, Oauth, MagicLink
}
private fun processSignIn(call: PluginCall, method: SignInMethod) {
launch(Dispatchers.IO) {
try {
val endpoint = call.getStringEnsure("endpoint")
val request = when (method) {
SignInMethod.Password -> {
val email = call.getStringEnsure("email")
val password = call.getStringEnsure("password")
val verifyToken = call.getString("verifyToken")
val challenge = call.getString("challenge")
val body = JSONObject()
.apply {
put("email", email)
put("password", password)
}
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
val requestBuilder = Request.Builder()
.url("$endpoint/api/auth/sign-in")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.post(body)
if (verifyToken != null) {
requestBuilder.addHeader("x-captcha-token", verifyToken)
}
if (challenge != null) {
requestBuilder.addHeader("x-captcha-challenge", challenge)
}
requestBuilder.build()
}
SignInMethod.Oauth -> {
val code = call.getStringEnsure("code")
val state = call.getStringEnsure("state")
val clientNonce = call.getString("clientNonce")
val body = JSONObject()
.apply {
put("code", code)
put("state", state)
put("client_nonce", clientNonce)
}
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
Request.Builder()
.url("$endpoint/api/oauth/callback")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.post(body)
.build()
}
SignInMethod.MagicLink -> {
val email = call.getStringEnsure("email")
val token = call.getStringEnsure("token")
val clientNonce = call.getString("clientNonce")
val body = JSONObject()
.apply {
put("email", email)
put("token", token)
put("client_nonce", clientNonce)
}
.toString()
.toRequestBody("application/json".toMediaTypeOrNull())
Request.Builder()
.url("$endpoint/api/auth/magic-link")
.header("x-affine-version", CapacitorConfig.getAffineVersion())
.post(body)
.build()
}
}
OkHttp.client.newCall(request).executeAsync().use { response ->
if (response.code >= 400) {
call.reject(response.body.string())
return@launch
}
CookieStore.getCookie(endpoint, CookieStore.AFFINE_SESSION)?.let {
Timber.i("$method sign in success.")
Timber.d("Update session [$it]")
call.resolve(JSObject().put("token", it))
} ?: run {
Timber.w("$method sign in fail, token not found.")
call.reject("$method sign in fail, token not found")
}
}
} catch (e: Exception) {
Timber.w(e, "$method sign in fail.")
call.reject("$method sign in fail.", null, e)
}
}
}
}
@@ -6,6 +6,7 @@ import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import timber.log.Timber
import uniffi.affine_mobile_native.hashcashMint
@CapacitorPlugin(name = "HashCash")
@@ -16,8 +17,10 @@ class HashCashPlugin : Plugin() {
launch(Dispatchers.IO) {
val challenge = call.getString("challenge") ?: ""
val bits = call.getInt("bits") ?: 20
val hash = hashcashMint(resource = challenge, bits = bits.toUInt())
Timber.i("hash:[ value = $hash ]")
call.resolve(JSObject().apply {
put("value", hashcashMint(resource = challenge, bits = bits.toUInt()))
put("value", hash)
})
}
}
@@ -28,7 +28,8 @@ class NbStorePlugin : Plugin() {
val spaceType = call.getStringEnsure("spaceType")
val peer = call.getStringEnsure("peer")
val appStoragePath = activity?.filesDir ?: run {
call.reject("Failed to connect storage, cannot access file system.")
Timber.w("Failed to connect storage, cannot access device file system.")
call.reject("Failed to connect storage, cannot access device file system.")
return@launch
}
val peerDir = appStoragePath.resolve("workspaces")
@@ -38,13 +39,15 @@ class NbStorePlugin : Plugin() {
.replace(Regex("_+"), "_")
.replace(Regex("_+$"), "")
)
Timber.d("connecting nbstore... peerDir[$peerDir]")
Timber.i("NbStore connecting... peerDir[$peerDir].")
peerDir.mkdirs()
val db = peerDir.resolve("$spaceId.db")
docStoragePool.connect(id, db.path)
Timber.i("NbStore connected [ id = $id ].")
call.resolve()
} catch (e: Exception) {
call.reject("Failed to connect storage", e)
Timber.e(e, "Failed to connect NbStore.")
call.reject("Failed to connect NbStore.", e)
}
}
}
@@ -55,9 +58,11 @@ class NbStorePlugin : Plugin() {
try {
val id = call.getStringEnsure("id")
docStoragePool.disconnect(universalId = id)
Timber.i("NbStore disconnected [ id = $id ].")
call.resolve()
} catch (e: Exception) {
call.reject("Failed to disconnect, ${e.message}", null, e)
Timber.e(e, "Failed to disconnect NbStore")
call.reject("Failed to disconnect NbStore", null, e)
}
}
}
@@ -69,8 +74,10 @@ class NbStorePlugin : Plugin() {
val id = call.getStringEnsure("id")
val spaceId = call.getStringEnsure("spaceId")
docStoragePool.setSpaceId(universalId = id, spaceId = spaceId)
Timber.i("Set space id: [ id = $id, spaceId = $spaceId ].")
call.resolve()
} catch (e: Exception) {
Timber.e(e, "Failed to set space id.")
call.reject("Failed to set space id, ${e.message}", null, e)
}
}
@@ -1,6 +1,6 @@
package app.affine.pro.repo
import app.affine.pro.ai.Prompt
import app.affine.pro.Prompt
import app.affine.pro.service.GraphQLClient
import com.affine.pro.graphql.CreateCopilotMessageMutation
import com.affine.pro.graphql.CreateCopilotSessionMutation
@@ -1,6 +1,14 @@
package app.affine.pro.service
import androidx.core.net.toUri
import app.affine.pro.AffineApp
import app.affine.pro.utils.dataStore
import app.affine.pro.utils.set
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
@@ -26,7 +34,6 @@ object OkHttp {
}
})
.addInterceptor(HttpLoggingInterceptor { msg ->
Timber.tag("Affine-Network")
Timber.d(msg)
}.apply {
level = HttpLoggingInterceptor.Level.BODY
@@ -37,10 +44,23 @@ object OkHttp {
object CookieStore {
const val AFFINE_SESSION = "affine_session"
const val AFFINE_USER_ID = "affine_user_id"
private val _cookies = ConcurrentHashMap<String, List<Cookie>>()
fun saveCookies(host: String, cookies: List<Cookie>) {
_cookies[host] = cookies
MainScope().launch(Dispatchers.IO) {
cookies.find { it.name == AFFINE_SESSION }?.let {
AffineApp.context().dataStore.set(AFFINE_SESSION, it.toString())
}
cookies.find { it.name == AFFINE_USER_ID }?.let {
Timber.d("Update user id [${it.value}]")
AffineApp.context().dataStore.set(AFFINE_USER_ID, it.toString())
Firebase.crashlytics.setUserId(it.value)
}
}
}
fun getCookies(host: String) = _cookies[host] ?: emptyList()
@@ -49,5 +69,4 @@ object CookieStore {
?.let { _cookies[it] }
?.find { cookie -> cookie.name == name }
?.value
}
@@ -0,0 +1,30 @@
package app.affine.pro.utils
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "affine")
suspend fun DataStore<Preferences>.set(key: String, value: String) {
edit {
it[stringPreferencesKey(key)] = value
}
}
suspend fun DataStore<Preferences>.get(key: String) = data.map {
it[stringPreferencesKey(key)] ?: ""
}.first()
suspend fun DataStore<Preferences>.clear(vararg keys: String) {
edit { prefs ->
keys.forEach { key ->
prefs[stringPreferencesKey(key)] = ""
}
}
}
@@ -0,0 +1,10 @@
package app.affine.pro.utils.logger
import timber.log.Timber
class AffineDebugTree : Timber.DebugTree() {
override fun createStackElementTag(element: StackTraceElement): String {
return "Affine:${super.createStackElementTag(element)}:${element.lineNumber}"
}
}
@@ -0,0 +1,36 @@
package app.affine.pro.utils.logger
import android.util.Log
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
import timber.log.Timber
class CrashlyticsTree : Timber.Tree() {
private val crashlytics = Firebase.crashlytics
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]"
}
crashlytics.log(
StringBuilder(level)
.append(tag?.let { "[$tag]" } ?: "")
.append(" ")
.append(message)
.toString()
)
if (t == null) return
crashlytics.recordException(t)
}
}