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

View File

@@ -205,6 +205,10 @@ jobs:
with: with:
name: android name: android
path: packages/frontend/apps/android/dist path: packages/frontend/apps/android/dist
- name: Load Google Service file
env:
DATA: ${{ secrets.FIREBASE_ANDROID_GOOGLE_SERVICE_JSON }}
run: echo $DATA | base64 -di > packages/frontend/apps/android/App/app/google-services.json
- name: Setup Node.js - name: Setup Node.js
uses: ./.github/actions/setup-node uses: ./.github/actions/setup-node
timeout-minutes: 10 timeout-minutes: 10

View File

@@ -1,2 +1,3 @@
/build/* /build/*
!/build/.npmkeep !/build/.npmkeep
google-services.json

View File

@@ -3,13 +3,15 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
plugins { plugins {
alias libs.plugins.android.application alias libs.plugins.android.application
alias libs.plugins.compose
alias libs.plugins.hilt
alias libs.plugins.kotlin.android alias libs.plugins.kotlin.android
alias libs.plugins.kotlin.parcelize alias libs.plugins.kotlin.parcelize
alias libs.plugins.kotlin.serialization alias libs.plugins.kotlin.serialization
alias libs.plugins.ksp alias libs.plugins.ksp
alias libs.plugins.rust.android alias libs.plugins.rust.android
alias libs.plugins.compose
alias libs.plugins.google.service
alias libs.plugins.firebase.crashlytics
alias libs.plugins.hilt
} }
apply from: 'capacitor.build.gradle' apply from: 'capacitor.build.gradle'
@@ -106,6 +108,7 @@ dependencies {
implementation libs.androidx.coordinatorlayout implementation libs.androidx.coordinatorlayout
implementation libs.androidx.core.ktx implementation libs.androidx.core.ktx
implementation libs.androidx.core.splashscreen implementation libs.androidx.core.splashscreen
implementation libs.androidx.datastore.preferences
implementation libs.androidx.navigation.fragment implementation libs.androidx.navigation.fragment
implementation libs.androidx.navigation.ui.ktx implementation libs.androidx.navigation.ui.ktx
@@ -118,8 +121,11 @@ dependencies {
implementation libs.kotlinx.coroutines.android implementation libs.kotlinx.coroutines.android
implementation libs.kotlinx.serialization.json implementation libs.kotlinx.serialization.json
def okhttpBom = platform(libs.okhttp.bom) implementation platform(libs.firebase.bom)
implementation okhttpBom implementation libs.firebase.analytics
implementation libs.firebase.crashlytics
implementation platform(libs.okhttp.bom)
implementation libs.okhttp implementation libs.okhttp
implementation libs.okhttp.coroutines implementation libs.okhttp.coroutines
implementation libs.okhttp.logging implementation libs.okhttp.logging
@@ -130,15 +136,6 @@ dependencies {
androidTestImplementation libs.androidx.espresso.core androidTestImplementation libs.androidx.espresso.core
} }
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch (Exception ignored) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
cargo { cargo {
module = "../../../../mobile-native" module = "../../../../mobile-native"
libname = "affine_mobile_native" libname = "affine_mobile_native"

View File

@@ -68,3 +68,8 @@
-keepclassmembers public class **$$serializer { -keepclassmembers public class **$$serializer {
private ** descriptor; private ** descriptor;
} }
# Keep file names and line numbers.
-keepattributes SourceFile,LineNumberTable
# Keep custom exceptions.
-keep public class * extends java.lang.Exception

View File

@@ -1,7 +1,22 @@
package app.affine.pro package app.affine.pro
import android.annotation.SuppressLint
import android.app.Application 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 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 import timber.log.Timber
@HiltAndroidApp @HiltAndroidApp
@@ -9,8 +24,47 @@ class AffineApp : Application() {
override fun onCreate() { override fun onCreate() {
super.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) 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)
}
} }

View File

@@ -1,4 +1,4 @@
package app.affine.pro.ai package app.affine.pro
enum class Prompt(val value: String) { enum class Prompt(val value: String) {
Summary("Summary"), Summary("Summary"),

View File

@@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext
@HiltViewModel @HiltViewModel
class ChatViewModel @Inject constructor( class ChatViewModel @Inject constructor(
@@ -37,10 +36,10 @@ class ChatViewModel @Inject constructor(
workspaceId = webRepo.workspaceId(), workspaceId = webRepo.workspaceId(),
docId = webRepo.docId(), docId = webRepo.docId(),
).getOrElse { ).getOrElse {
Timber.d("Create session failed") Timber.w(it, "Create session failed")
return@launch return@launch
} }
Timber.d("Create session: $sessionId") Timber.i("Create session success:[ sessionId = $sessionId].")
val historyMessages = graphQLRepo.getCopilotHistories( val historyMessages = graphQLRepo.getCopilotHistories(
workspaceId = webRepo.workspaceId(), workspaceId = webRepo.workspaceId(),
docId = webRepo.docId(), docId = webRepo.docId(),
@@ -60,16 +59,12 @@ class ChatViewModel @Inject constructor(
sessionId = sessionId, sessionId = sessionId,
message = message, message = message,
).onSuccess { messageId -> ).onSuccess { messageId ->
Timber.d("send message: $messageId") Timber.i("send message: $messageId")
sseRepo.messageStream(sessionId, messageId) sseRepo.messageStream(sessionId, messageId)
.onEach { .onEach {
Timber.d("$coroutineContext") Timber.d("On sse message: ${it.getOrNull()}")
Timber.d("on message: ${it.getOrNull()}")
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.onEach {
Timber.d("$coroutineContext")
}
.collect() .collect()
} }
} }
@@ -80,7 +75,7 @@ class ChatViewModel @Inject constructor(
docId = webRepo.docId(), docId = webRepo.docId(),
).onSuccess { id -> ).onSuccess { id ->
sessionId = id sessionId = id
Timber.d("Create session: $id") Timber.i("Create session: $id")
sendMessage() sendMessage()
}.onFailure { }.onFailure {
Timber.e(it, "Create session failed.") Timber.e(it, "Create session failed.")

View File

@@ -4,6 +4,7 @@ import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import timber.log.Timber
@CapacitorPlugin(name = "AIButton") @CapacitorPlugin(name = "AIButton")
class AIButtonPlugin : Plugin() { class AIButtonPlugin : Plugin() {
@@ -16,6 +17,7 @@ class AIButtonPlugin : Plugin() {
@PluginMethod @PluginMethod
fun present(call: PluginCall) { fun present(call: PluginCall) {
launch { launch {
Timber.i("present AIButton")
(activity as? Callback)?.present() (activity as? Callback)?.present()
call.resolve() call.resolve()
} }
@@ -24,6 +26,7 @@ class AIButtonPlugin : Plugin() {
@PluginMethod @PluginMethod
fun dismiss(call: PluginCall) { fun dismiss(call: PluginCall) {
launch { launch {
Timber.i("dismiss AIButton")
(activity as? Callback)?.dismiss() (activity as? Callback)?.dismiss()
call.resolve() call.resolve()
} }

View File

@@ -4,6 +4,7 @@ import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import timber.log.Timber
@CapacitorPlugin(name = "AffineTheme") @CapacitorPlugin(name = "AffineTheme")
class AffineThemePlugin : Plugin() { class AffineThemePlugin : Plugin() {
@@ -14,7 +15,9 @@ class AffineThemePlugin : Plugin() {
@PluginMethod @PluginMethod
fun onThemeChanged(call: PluginCall) { 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() call.resolve()
} }
} }

View File

@@ -1,9 +1,12 @@
package app.affine.pro.plugin package app.affine.pro.plugin
import android.annotation.SuppressLint import android.annotation.SuppressLint
import app.affine.pro.AffineApp
import app.affine.pro.CapacitorConfig import app.affine.pro.CapacitorConfig
import app.affine.pro.service.CookieStore import app.affine.pro.service.CookieStore
import app.affine.pro.service.OkHttp 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.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
@@ -16,6 +19,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync import okhttp3.coroutines.executeAsync
import org.json.JSONObject import org.json.JSONObject
import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@CapacitorPlugin(name = "Auth") @CapacitorPlugin(name = "Auth")
@@ -23,120 +27,18 @@ class AuthPlugin : Plugin() {
@PluginMethod @PluginMethod
fun signInMagicLink(call: PluginCall) { fun signInMagicLink(call: PluginCall) {
launch(Dispatchers.IO) { processSignIn(call, SignInMethod.MagicLink)
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)
}
}
} }
@PluginMethod @PluginMethod
fun signInOauth(call: PluginCall) { fun signInOauth(call: PluginCall) {
launch(Dispatchers.IO) { processSignIn(call, SignInMethod.Oauth)
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)
}
}
} }
@SuppressLint("BuildListAdds") @SuppressLint("BuildListAdds")
@PluginMethod @PluginMethod
fun signInPassword(call: PluginCall) { fun signInPassword(call: PluginCall) {
launch(Dispatchers.IO) { processSignIn(call, SignInMethod.Password)
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)
}
}
} }
@PluginMethod @PluginMethod
@@ -154,11 +56,110 @@ class AuthPlugin : Plugin() {
call.reject(response.body.string()) call.reject(response.body.string())
return@launch return@launch
} }
Timber.i("Sign out success.")
call.resolve(JSObject().put("ok", true)) call.resolve(JSObject().put("ok", true))
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.w(e, "Sign out fail.")
call.reject("Failed to sign out, $e", null, e) 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)
}
}
}
} }

View File

@@ -6,6 +6,7 @@ import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import timber.log.Timber
import uniffi.affine_mobile_native.hashcashMint import uniffi.affine_mobile_native.hashcashMint
@CapacitorPlugin(name = "HashCash") @CapacitorPlugin(name = "HashCash")
@@ -16,8 +17,10 @@ class HashCashPlugin : Plugin() {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
val challenge = call.getString("challenge") ?: "" val challenge = call.getString("challenge") ?: ""
val bits = call.getInt("bits") ?: 20 val bits = call.getInt("bits") ?: 20
val hash = hashcashMint(resource = challenge, bits = bits.toUInt())
Timber.i("hash:[ value = $hash ]")
call.resolve(JSObject().apply { call.resolve(JSObject().apply {
put("value", hashcashMint(resource = challenge, bits = bits.toUInt())) put("value", hash)
}) })
} }
} }

View File

@@ -28,7 +28,8 @@ class NbStorePlugin : Plugin() {
val spaceType = call.getStringEnsure("spaceType") val spaceType = call.getStringEnsure("spaceType")
val peer = call.getStringEnsure("peer") val peer = call.getStringEnsure("peer")
val appStoragePath = activity?.filesDir ?: run { 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 return@launch
} }
val peerDir = appStoragePath.resolve("workspaces") val peerDir = appStoragePath.resolve("workspaces")
@@ -38,13 +39,15 @@ class NbStorePlugin : Plugin() {
.replace(Regex("_+"), "_") .replace(Regex("_+"), "_")
.replace(Regex("_+$"), "") .replace(Regex("_+$"), "")
) )
Timber.d("connecting nbstore... peerDir[$peerDir]") Timber.i("NbStore connecting... peerDir[$peerDir].")
peerDir.mkdirs() peerDir.mkdirs()
val db = peerDir.resolve("$spaceId.db") val db = peerDir.resolve("$spaceId.db")
docStoragePool.connect(id, db.path) docStoragePool.connect(id, db.path)
Timber.i("NbStore connected [ id = $id ].")
call.resolve() call.resolve()
} catch (e: Exception) { } 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 { try {
val id = call.getStringEnsure("id") val id = call.getStringEnsure("id")
docStoragePool.disconnect(universalId = id) docStoragePool.disconnect(universalId = id)
Timber.i("NbStore disconnected [ id = $id ].")
call.resolve() call.resolve()
} catch (e: Exception) { } 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 id = call.getStringEnsure("id")
val spaceId = call.getStringEnsure("spaceId") val spaceId = call.getStringEnsure("spaceId")
docStoragePool.setSpaceId(universalId = id, spaceId = spaceId) docStoragePool.setSpaceId(universalId = id, spaceId = spaceId)
Timber.i("Set space id: [ id = $id, spaceId = $spaceId ].")
call.resolve() call.resolve()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Failed to set space id.")
call.reject("Failed to set space id, ${e.message}", null, e) call.reject("Failed to set space id, ${e.message}", null, e)
} }
} }

View File

@@ -1,6 +1,6 @@
package app.affine.pro.repo package app.affine.pro.repo
import app.affine.pro.ai.Prompt import app.affine.pro.Prompt
import app.affine.pro.service.GraphQLClient import app.affine.pro.service.GraphQLClient
import com.affine.pro.graphql.CreateCopilotMessageMutation import com.affine.pro.graphql.CreateCopilotMessageMutation
import com.affine.pro.graphql.CreateCopilotSessionMutation import com.affine.pro.graphql.CreateCopilotSessionMutation

View File

@@ -1,6 +1,14 @@
package app.affine.pro.service package app.affine.pro.service
import androidx.core.net.toUri 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.Cookie
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.HttpUrl import okhttp3.HttpUrl
@@ -26,7 +34,6 @@ object OkHttp {
} }
}) })
.addInterceptor(HttpLoggingInterceptor { msg -> .addInterceptor(HttpLoggingInterceptor { msg ->
Timber.tag("Affine-Network")
Timber.d(msg) Timber.d(msg)
}.apply { }.apply {
level = HttpLoggingInterceptor.Level.BODY level = HttpLoggingInterceptor.Level.BODY
@@ -37,10 +44,23 @@ object OkHttp {
object CookieStore { object CookieStore {
const val AFFINE_SESSION = "affine_session"
const val AFFINE_USER_ID = "affine_user_id"
private val _cookies = ConcurrentHashMap<String, List<Cookie>>() private val _cookies = ConcurrentHashMap<String, List<Cookie>>()
fun saveCookies(host: String, cookies: List<Cookie>) { fun saveCookies(host: String, cookies: List<Cookie>) {
_cookies[host] = cookies _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() fun getCookies(host: String) = _cookies[host] ?: emptyList()
@@ -49,5 +69,4 @@ object CookieStore {
?.let { _cookies[it] } ?.let { _cookies[it] }
?.find { cookie -> cookie.name == name } ?.find { cookie -> cookie.name == name }
?.value ?.value
} }

View File

@@ -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)] = ""
}
}
}

View File

@@ -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}"
}
}

View File

@@ -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)
}
}

View File

@@ -21,15 +21,17 @@ plugins {
alias libs.plugins.version.catalog.update alias libs.plugins.version.catalog.update
alias libs.plugins.android.application apply false alias libs.plugins.android.application apply false
alias libs.plugins.android.library apply false alias libs.plugins.android.library apply false
alias libs.plugins.apollo.android apply false
alias libs.plugins.compose apply false
alias libs.plugins.firebase.crashlytics apply false
alias libs.plugins.google.service apply false
alias libs.plugins.hilt apply false
alias libs.plugins.jetbrains.kotlin.jvm apply false
alias libs.plugins.kotlin.android apply false alias libs.plugins.kotlin.android apply false
alias libs.plugins.kotlin.parcelize apply false alias libs.plugins.kotlin.parcelize apply false
alias libs.plugins.kotlin.serialization apply false alias libs.plugins.kotlin.serialization apply false
alias libs.plugins.compose apply false
alias libs.plugins.ksp apply false alias libs.plugins.ksp apply false
alias libs.plugins.hilt apply false
alias libs.plugins.rust.android apply false alias libs.plugins.rust.android apply false
alias libs.plugins.apollo.android apply false
alias libs.plugins.jetbrains.kotlin.jvm apply false
} }
apply from: "variables.gradle" apply from: "variables.gradle"

View File

@@ -1,13 +1,11 @@
versionCatalogUpdate { versionCatalogUpdate {
sortByKey.set(true) sortByKey = true
versionCatalogs {
keep { special {
// keep versions without any library or plugin reference keep {
keepUnusedVersions.set(true) keepUnusedVersions = true
// keep all libraries that aren't used in the project }
keepUnusedLibraries.set(true) }
// keep all plugins that aren't used in the project
keepUnusedPlugins.set(true)
} }
} }

View File

@@ -1,5 +1,4 @@
[versions] [versions]
# @keep
android-gradle-plugin = "8.9.1" android-gradle-plugin = "8.9.1"
androidx-activity-compose = "1.10.1" androidx-activity-compose = "1.10.1"
androidx-appcompat = "1.7.0" androidx-appcompat = "1.7.0"
@@ -8,6 +7,7 @@ androidx-compose-bom = "2025.04.00"
androidx-coordinatorlayout = "1.3.0" androidx-coordinatorlayout = "1.3.0"
androidx-core-ktx = "1.16.0" androidx-core-ktx = "1.16.0"
androidx-core-splashscreen = "1.0.1" androidx-core-splashscreen = "1.0.1"
androidx-datastore-preferences = "1.1.4"
androidx-espresso-core = "3.6.1" androidx-espresso-core = "3.6.1"
androidx-junit = "1.2.1" androidx-junit = "1.2.1"
androidx-lifecycle-compose = "2.8.7" androidx-lifecycle-compose = "2.8.7"
@@ -17,6 +17,8 @@ apollo = "4.1.1"
apollo-kotlin-adapters = "0.0.4" apollo-kotlin-adapters = "0.0.4"
# @keep # @keep
compileSdk = "35" compileSdk = "35"
firebase-bom = "33.12.0"
firebase-crashlytics = "3.0.3"
google-services = "4.4.2" google-services = "4.4.2"
gradle-versions = "0.52.0" gradle-versions = "0.52.0"
hilt = "2.56.1" hilt = "2.56.1"
@@ -31,11 +33,11 @@ ksp = "2.1.20-2.0.0"
# @keep # @keep
minSdk = "22" minSdk = "22"
mozilla-rust-android = "0.9.6" mozilla-rust-android = "0.9.6"
okhttp = "5.0.0-alpha.14" okhttp-bom = "5.0.0-alpha.14"
# @keep # @keep
targetSdk = "35" targetSdk = "35"
timber = "5.0.1" timber = "5.0.1"
version-catalog-update = "0.8.5" version-catalog-update = "1.0.0"
[libraries] [libraries]
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" } android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" }
@@ -56,6 +58,7 @@ androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding
androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "androidx-coordinatorlayout" } androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "androidx-coordinatorlayout" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore-preferences" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso-core" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso-core" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-compose" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-compose" }
@@ -63,10 +66,13 @@ androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-v
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" }
androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" }
apollo-adapters-core = { module = "com.apollographql.adapters:apollo-adapters-core", version.ref = "apollo-kotlin-adapters"} apollo-adapters-core = { module = "com.apollographql.adapters:apollo-adapters-core", version.ref = "apollo-kotlin-adapters" }
apollo-adapters-kotlinx-datetime = { module = "com.apollographql.adapters:apollo-adapters-kotlinx-datetime", version.ref = "apollo-kotlin-adapters"} apollo-adapters-kotlinx-datetime = { module = "com.apollographql.adapters:apollo-adapters-kotlinx-datetime", version.ref = "apollo-kotlin-adapters" }
apollo-api = { module = "com.apollographql.apollo:apollo-api", version.ref = "apollo" } apollo-api = { module = "com.apollographql.apollo:apollo-api", version.ref = "apollo" }
apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" }
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" }
google-services = { module = "com.google.gms:google-services", version.ref = "google-services" } 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-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
@@ -79,8 +85,8 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
okhttp = { module = "com.squareup.okhttp3:okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp" }
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp-bom" }
okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines" } okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines" }
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor" }
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse" } okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
@@ -90,6 +96,8 @@ android-application = { id = "com.android.application", version.ref = "android-g
android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" }
apollo-android = { id = "com.apollographql.apollo", version.ref = "apollo" } apollo-android = { id = "com.apollographql.apollo", version.ref = "apollo" }
compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase-crashlytics" }
google-service = { id = "com.google.gms.google-services", version.ref = "google-services" }
gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" } gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }