feat(android): support self-host & multi channels (#12095)

This commit is contained in:
Aki Chang
2025-05-06 16:13:20 +08:00
committed by GitHub
parent 88ceeba5b6
commit 24f52ed649
16 changed files with 225 additions and 191 deletions

View File

@@ -53,14 +53,10 @@ android {
}
flavorDimensions = ['chanel']
productFlavors {
stable {
buildConfigField 'String', 'BASE_URL', '"https://app.affine.pro"'
resValue 'string', 'host', '"app.affine.pro"'
}
canary {
buildConfigField 'String', 'BASE_URL', '"https://affine.fail"'
resValue 'string', 'host', '"affine.fail"'
}
stable
beta
internal
canary
}
compileOptions {

View File

@@ -3,9 +3,6 @@ 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 app.affine.pro.utils.logger.FileTree
@@ -13,11 +10,6 @@ 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
@@ -39,28 +31,6 @@ class AffineApp : Application() {
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)
FileTree.get()?.checkAndUploadOldLogs()
} catch (e: Exception) {
Timber.w(e, "[init] load persistent cookies fail.")
}
}
}
override fun onTerminate() {

View File

@@ -0,0 +1,52 @@
package app.affine.pro
import android.webkit.WebView
import app.affine.pro.service.CookieStore
import app.affine.pro.utils.dataStore
import app.affine.pro.utils.get
import app.affine.pro.utils.getCurrentServerBaseUrl
import app.affine.pro.utils.logger.FileTree
import com.getcapacitor.Bridge
import com.getcapacitor.WebViewListener
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import okhttp3.Cookie
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber
object AuthInitializer {
fun initialize(bridge: Bridge) {
bridge.addWebViewListener(object : WebViewListener() {
override fun onPageLoaded(webView: WebView?) {
bridge.removeWebViewListener(this)
MainScope().launch(Dispatchers.IO) {
try {
val server = bridge.getCurrentServerBaseUrl().toHttpUrl()
val sessionCookieStr = AffineApp.context().dataStore
.get(server.host + CookieStore.AFFINE_SESSION)
val userIdCookieStr = AffineApp.context().dataStore
.get(server.host + 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.")
val cookies = listOf(
Cookie.parse(server, sessionCookieStr)
?: error("Parse session cookie fail:[ cookie = $sessionCookieStr ]"),
Cookie.parse(server, userIdCookieStr)
?: error("Parse user id cookie fail:[ cookie = $userIdCookieStr ]"),
)
CookieStore.saveCookies(server.host, cookies)
FileTree.get()?.checkAndUploadOldLogs(server)
} catch (e: Exception) {
Timber.w(e, "[init] load persistent cookies fail.")
}
}
}
})
}
}

View File

@@ -13,7 +13,9 @@ import app.affine.pro.plugin.AffineThemePlugin
import app.affine.pro.plugin.AuthPlugin
import app.affine.pro.plugin.HashCashPlugin
import app.affine.pro.plugin.NbStorePlugin
import app.affine.pro.repo.WebRepo
import app.affine.pro.service.GraphQLService
import app.affine.pro.service.SSEService
import app.affine.pro.service.WebService
import app.affine.pro.utils.dp
import com.getcapacitor.BridgeActivity
import com.google.android.material.floatingactionbutton.FloatingActionButton
@@ -27,7 +29,13 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugi
View.OnClickListener {
@Inject
lateinit var webRepo: WebRepo
lateinit var webService: WebService
@Inject
lateinit var sseService: SSEService
@Inject
lateinit var graphQLService: GraphQLService
init {
registerPlugins(
@@ -56,6 +64,11 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugi
}
}
override fun load() {
super.load()
AuthInitializer.initialize(bridge)
}
override fun present() {
lifecycleScope.launch {
fab.show()
@@ -85,7 +98,9 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugi
override fun onClick(v: View) {
lifecycleScope.launch {
webRepo.init(bridge)
webService.update(bridge)
sseService.updateServer(bridge)
graphQLService.updateServer(bridge)
AIActivity.open(this@MainActivity)
}
}

View File

@@ -2,9 +2,9 @@ package app.affine.pro.ai.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.affine.pro.repo.GraphQLRepo
import app.affine.pro.repo.SSERepo
import app.affine.pro.repo.WebRepo
import app.affine.pro.service.GraphQLService
import app.affine.pro.service.SSEService
import app.affine.pro.service.WebService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,9 +18,9 @@ import javax.inject.Inject
@HiltViewModel
class ChatViewModel @Inject constructor(
private val webRepo: WebRepo,
private val graphQLRepo: GraphQLRepo,
private val sseRepo: SSERepo,
private val webService: WebService,
private val graphQLService: GraphQLService,
private val sseService: SSEService,
) : ViewModel() {
private lateinit var sessionId: String
@@ -32,17 +32,17 @@ class ChatViewModel @Inject constructor(
init {
viewModelScope.launch {
sessionId = graphQLRepo.createCopilotSession(
workspaceId = webRepo.workspaceId(),
docId = webRepo.docId(),
sessionId = graphQLService.createCopilotSession(
workspaceId = webService.workspaceId(),
docId = webService.docId(),
).getOrElse {
Timber.w(it, "Create session failed")
return@launch
}
Timber.i("Create session success:[ sessionId = $sessionId].")
val historyMessages = graphQLRepo.getCopilotHistories(
workspaceId = webRepo.workspaceId(),
docId = webRepo.docId(),
val historyMessages = graphQLService.getCopilotHistories(
workspaceId = webService.workspaceId(),
docId = webService.docId(),
sessionId = sessionId,
).getOrDefault(emptyList()).map {
ChatMessage.from(it)
@@ -55,12 +55,12 @@ class ChatViewModel @Inject constructor(
fun sendMessage(message: String) {
val sendMessage = suspend {
graphQLRepo.createCopilotMessage(
graphQLService.createCopilotMessage(
sessionId = sessionId,
message = message,
).onSuccess { messageId ->
Timber.i("send message: $messageId")
sseRepo.messageStream(sessionId, messageId)
sseService.messageStream(sessionId, messageId)
.onEach {
Timber.d("On sse message: ${it.getOrNull()}")
}
@@ -70,9 +70,9 @@ class ChatViewModel @Inject constructor(
}
viewModelScope.launch {
if (!this@ChatViewModel::sessionId.isInitialized) {
graphQLRepo.getCopilotSession(
workspaceId = webRepo.workspaceId(),
docId = webRepo.docId(),
graphQLService.getCopilotSession(
workspaceId = webService.workspaceId(),
docId = webService.docId(),
).onSuccess { id ->
sessionId = id
Timber.i("Create session: $id")

View File

@@ -11,6 +11,7 @@ import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -144,7 +145,7 @@ class AuthPlugin : Plugin() {
call.reject(response.body.string())
return@launch
}
CookieStore.getCookie(endpoint, CookieStore.AFFINE_SESSION)?.let {
CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_SESSION)?.let {
Timber.i("$method sign in success.")
Timber.d("Update session [$it]")
call.resolve(JSObject().put("token", it))

View File

@@ -1,35 +0,0 @@
package app.affine.pro.repo
import com.getcapacitor.Bridge
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Singleton
class WebRepo @Inject constructor() {
suspend fun init(bridge: Bridge) {
_workspaceId = eval(bridge, "window.getCurrentWorkspaceId()")
_docId = eval(bridge, "window.getCurrentDocId()")
_docContentInMD = eval(bridge, "window.getCurrentDocContentInMarkdown()")
}
private suspend fun eval(bridge: Bridge, js: String): String {
return suspendCoroutine { continuation ->
bridge.eval(js) { result ->
continuation.resume(result)
}
}
}
private lateinit var _workspaceId: String
private lateinit var _docId: String
private lateinit var _docContentInMD: String
fun workspaceId() = _workspaceId
fun docId() = _docId
fun docContentInMD() = _docContentInMD
}

View File

@@ -1,43 +0,0 @@
package app.affine.pro.service
import app.affine.pro.BuildConfig
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Mutation
import com.apollographql.apollo.api.Query
import com.apollographql.apollo.api.Subscription
import com.apollographql.apollo.network.okHttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GraphQLClient @Inject constructor() {
private val _client: ApolloClient by lazy {
ApolloClient.Builder().serverUrl("${BuildConfig.BASE_URL}/graphql")
.okHttpClient(OkHttp.client)
.build()
}
suspend fun <D : Query.Data> query(query: Query<D>) = withContext(Dispatchers.IO) {
runCatching {
withContext(Dispatchers.IO) {
_client.query(query).execute().dataOrThrow()
}
}
}
suspend fun <D : Mutation.Data> mutation(mutation: Mutation<D>) = withContext(Dispatchers.IO) {
runCatching {
_client.mutation(mutation).execute().dataOrThrow()
}
}
suspend fun <D : Subscription.Data> subscription(subscription: Subscription<D>) =
withContext(Dispatchers.IO) {
runCatching {
_client.subscription(subscription).execute().dataOrThrow()
}
}
}

View File

@@ -1,23 +1,29 @@
package app.affine.pro.repo
package app.affine.pro.service
import app.affine.pro.Prompt
import app.affine.pro.service.GraphQLClient
import app.affine.pro.utils.getCurrentServerBaseUrl
import com.affine.pro.graphql.CreateCopilotMessageMutation
import com.affine.pro.graphql.CreateCopilotSessionMutation
import com.affine.pro.graphql.GetCopilotHistoriesQuery
import com.affine.pro.graphql.GetCopilotSessionsQuery
import com.affine.pro.graphql.type.CreateChatMessageInput
import com.affine.pro.graphql.type.CreateChatSessionInput
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Mutation
import com.apollographql.apollo.api.Optional
import com.apollographql.apollo.api.Query
import com.apollographql.apollo.api.Subscription
import com.apollographql.apollo.network.okHttpClient
import com.getcapacitor.Bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GraphQLRepo @Inject constructor(
private val client: GraphQLClient
) {
class GraphQLService @Inject constructor() {
suspend fun getCopilotSession(workspaceId: String, docId: String) = client.query(
suspend fun getCopilotSession(workspaceId: String, docId: String) = query(
GetCopilotSessionsQuery(
workspaceId = workspaceId,
docId = Optional.present(docId)
@@ -30,7 +36,7 @@ class GraphQLRepo @Inject constructor(
workspaceId: String,
docId: String,
prompt: Prompt = Prompt.ChatWithAFFiNEAI
) = client.mutation(
) = mutation(
CreateCopilotSessionMutation(
CreateChatSessionInput(
docId = docId,
@@ -46,7 +52,7 @@ class GraphQLRepo @Inject constructor(
workspaceId: String,
docId: String,
sessionId: String,
) = client.query(
) = query(
GetCopilotHistoriesQuery(
workspaceId = workspaceId,
docId = Optional.present(docId),
@@ -60,15 +66,49 @@ class GraphQLRepo @Inject constructor(
suspend fun createCopilotMessage(
sessionId: String,
message: String,
) = client.mutation(CreateCopilotMessageMutation(
CreateChatMessageInput(
sessionId = sessionId,
content = Optional.present(message)
) = mutation(
CreateCopilotMessageMutation(
CreateChatMessageInput(
sessionId = sessionId,
content = Optional.present(message)
)
)
)).mapCatching { data ->
).mapCatching { data ->
data.createCopilotMessage
}
suspend fun updateServer(bridge: Bridge) {
val server = bridge.getCurrentServerBaseUrl()
if (this::_client.isInitialized && _client.newBuilder().httpServerUrl == server) return
_client = ApolloClient.Builder().serverUrl("$server/graphql")
.okHttpClient(OkHttp.client)
.build()
}
private lateinit var _client: ApolloClient
private suspend fun <D : Query.Data> query(query: Query<D>) = withContext(Dispatchers.IO) {
runCatching {
withContext(Dispatchers.IO) {
_client.query(query).execute().dataOrThrow()
}
}
}
private suspend fun <D : Mutation.Data> mutation(mutation: Mutation<D>) =
withContext(Dispatchers.IO) {
runCatching {
_client.mutation(mutation).execute().dataOrThrow()
}
}
private suspend fun <D : Subscription.Data> subscription(subscription: Subscription<D>) =
withContext(Dispatchers.IO) {
runCatching {
_client.subscription(subscription).execute().dataOrThrow()
}
}
companion object {
private const val ERROR_NULL_SESSION_ID = "null session id."
}

View File

@@ -1,6 +1,5 @@
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
@@ -53,11 +52,11 @@ object CookieStore {
_cookies[host] = cookies
MainScope().launch(Dispatchers.IO) {
cookies.find { it.name == AFFINE_SESSION }?.let {
AffineApp.context().dataStore.set(AFFINE_SESSION, it.toString())
AffineApp.context().dataStore.set(host + 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())
AffineApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString())
Firebase.crashlytics.setUserId(it.value)
}
}
@@ -65,8 +64,8 @@ object CookieStore {
fun getCookies(host: String) = _cookies[host] ?: emptyList()
fun getCookie(url: String, name: String) = url.toUri().host
?.let { _cookies[it] }
fun getCookie(url: HttpUrl, name: String) = url.host
.let { _cookies[it] }
?.find { cookie -> cookie.name == name }
?.value
}

View File

@@ -1,7 +1,7 @@
package app.affine.pro.repo
package app.affine.pro.service
import app.affine.pro.BuildConfig
import app.affine.pro.service.OkHttp
import app.affine.pro.utils.getCurrentServerBaseUrl
import com.getcapacitor.Bridge
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
@@ -17,10 +17,16 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SSERepo @Inject constructor() {
class SSEService @Inject constructor() {
private lateinit var _server: String
suspend fun updateServer(bridge: Bridge) {
_server = bridge.getCurrentServerBaseUrl()
}
fun messageStream(sessionId: String, messageId: String) =
"${BuildConfig.BASE_URL}/api/copilot/chat/$sessionId/stream?messageId=$messageId".eventSource()
"$_server/api/copilot/chat/$sessionId/stream?messageId=$messageId".eventSource()
data class Event(val id: String?, val type: String?, val data: String)

View File

@@ -0,0 +1,28 @@
package app.affine.pro.service
import app.affine.pro.utils.getCurrentDocContentInMarkdown
import app.affine.pro.utils.getCurrentDocId
import app.affine.pro.utils.getCurrentWorkspaceId
import com.getcapacitor.Bridge
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WebService @Inject constructor() {
suspend fun update(bridge: Bridge) {
_workspaceId = bridge.getCurrentWorkspaceId()
_docId = bridge.getCurrentDocId()
_docContentInMD = bridge.getCurrentDocContentInMarkdown()
}
private lateinit var _workspaceId: String
private lateinit var _docId: String
private lateinit var _docContentInMD: String
fun workspaceId() = _workspaceId
fun docId() = _docId
fun docContentInMD() = _docContentInMD
}

View File

@@ -1,18 +0,0 @@
package app.affine.pro.service.interceptor
import android.webkit.CookieManager
import app.affine.pro.BuildConfig
import com.apollographql.apollo.api.http.HttpRequest
import com.apollographql.apollo.network.http.HttpInterceptor
import com.apollographql.apollo.network.http.HttpInterceptorChain
object CookieInterceptor : HttpInterceptor {
override suspend fun intercept(
request: HttpRequest,
chain: HttpInterceptorChain
) = chain.proceed(
request.newBuilder().addHeader(
"Cookie", CookieManager.getInstance().getCookie(BuildConfig.BASE_URL)
).build()
)
}

View File

@@ -19,12 +19,4 @@ suspend fun DataStore<Preferences>.set(key: String, value: String) {
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)] = ""
}
}
}
}.first()

View File

@@ -0,0 +1,30 @@
package app.affine.pro.utils
import com.getcapacitor.Bridge
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun Bridge.getCurrentServerBaseUrl() = eval("window.getCurrentServerBaseUrl()").strict()
suspend fun Bridge.getCurrentWorkspaceId() = eval("window.getCurrentWorkspaceId()").strict()
suspend fun Bridge.getCurrentDocId() = eval("window.getCurrentDocId()").strict()
suspend fun Bridge.getCurrentDocContentInMarkdown() =
eval("window.getCurrentDocContentInMarkdown()").strict()
private suspend fun Bridge.eval(js: String): String {
return suspendCoroutine { continuation ->
eval(js) { result ->
continuation.resume(result)
}
}
}
private fun String.strict() = let {
if (startsWith("\"") && endsWith("\"")) {
substring(1, lastIndex)
} else {
this
}
}

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
@@ -41,19 +42,19 @@ class FileTree(context: Context) : Timber.Tree() {
}
}
suspend fun checkAndUploadOldLogs() {
suspend fun checkAndUploadOldLogs(server: HttpUrl) {
val today = dateFormat.format(Date())
logDirectory.listFiles()?.forEach { file ->
val fileName = file.name
if (fileName.endsWith(".log") && !fileName.startsWith(today)) {
uploadLogToFirebase(file)
uploadLogToFirebase(server, file)
}
}
}
private suspend fun uploadLogToFirebase(file: File) =
private suspend fun uploadLogToFirebase(server: HttpUrl, file: File) =
suspendCancellableCoroutine { continuation ->
val user = CookieStore.getCookie(BuildConfig.BASE_URL, CookieStore.AFFINE_USER_ID)
val user = CookieStore.getCookie(server, CookieStore.AFFINE_USER_ID)
?: return@suspendCancellableCoroutine
val storageRef = Firebase.storage.reference
val logFileRef = storageRef.child("android_log/$user/${file.name}")