mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
feat(android): ai chat scaffold (#11124)
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: eyhn <cneyhn@gmail.com>
This commit is contained in:
2
.github/workflows/release-mobile.yml
vendored
2
.github/workflows/release-mobile.yml
vendored
@@ -263,5 +263,5 @@ jobs:
|
||||
packageName: app.affine.pro
|
||||
releaseFiles: packages/frontend/apps/android/App/app/build/outputs/bundle/${{ env.BUILD_TYPE }}Release/app-${{ env.BUILD_TYPE }}-release-signed.aab
|
||||
track: internal
|
||||
status: draft
|
||||
status: complete
|
||||
existingEditId: ${{ steps.bump.outputs.EDIT_ID }}
|
||||
|
||||
1
packages/frontend/apps/android/.gitignore
vendored
1
packages/frontend/apps/android/.gitignore
vendored
@@ -4,6 +4,7 @@ App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
*.log
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
@@ -3,7 +3,12 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
|
||||
|
||||
plugins {
|
||||
alias libs.plugins.android.application
|
||||
alias libs.plugins.compose
|
||||
alias libs.plugins.hilt
|
||||
alias libs.plugins.kotlin.android
|
||||
alias libs.plugins.kotlin.parcelize
|
||||
alias libs.plugins.kotlin.serialization
|
||||
alias libs.plugins.ksp
|
||||
alias libs.plugins.rust.android
|
||||
}
|
||||
|
||||
@@ -26,17 +31,23 @@ android {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
buildConfig true
|
||||
viewBinding true
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
debug {
|
||||
minifyEnabled false
|
||||
debuggable true
|
||||
}
|
||||
}
|
||||
flavorDimensions = ['chanel']
|
||||
productFlavors {
|
||||
@@ -66,17 +77,54 @@ dependencies {
|
||||
implementation project(':capacitor-android')
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
implementation project(':service')
|
||||
implementation libs.kotlinx.coroutines.core
|
||||
implementation libs.kotlinx.coroutines.android
|
||||
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.androidx.browser
|
||||
implementation libs.timber
|
||||
|
||||
implementation libs.hilt.android.core
|
||||
ksp libs.hilt.compiler
|
||||
|
||||
def composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation composeBom
|
||||
implementation libs.androidx.activity.compose
|
||||
implementation libs.androidx.compose.foundation.layout
|
||||
implementation libs.androidx.compose.material3
|
||||
implementation libs.androidx.compose.material.icons.extended
|
||||
implementation libs.androidx.compose.runtime.livedata
|
||||
implementation libs.androidx.compose.ui.tooling.preview
|
||||
implementation libs.androidx.compose.ui.util
|
||||
implementation libs.androidx.compose.ui.viewbinding
|
||||
implementation libs.androidx.compose.ui.googlefonts
|
||||
implementation libs.androidx.lifecycle.viewModelCompose
|
||||
implementation libs.androidx.lifecycle.runtime.compose
|
||||
implementation libs.androidx.navigation.compose
|
||||
debugImplementation composeBom
|
||||
debugImplementation libs.androidx.compose.ui.test.manifest
|
||||
debugImplementation libs.androidx.compose.ui.tooling
|
||||
|
||||
implementation libs.androidx.coordinatorlayout
|
||||
implementation libs.androidx.core.splashscreen
|
||||
implementation libs.androidx.core.ktx
|
||||
implementation libs.androidx.material3
|
||||
implementation libs.androidx.core.splashscreen
|
||||
implementation libs.androidx.navigation.fragment
|
||||
implementation libs.androidx.navigation.ui.ktx
|
||||
|
||||
implementation libs.apollo.runtime
|
||||
implementation libs.google.material
|
||||
implementation libs.jna
|
||||
implementation (libs.jna) {
|
||||
artifact {
|
||||
type = 'aar'
|
||||
}
|
||||
}
|
||||
implementation libs.kotlinx.coroutines.android
|
||||
implementation libs.kotlinx.serialization.json
|
||||
|
||||
def okhttpBom = platform(libs.okhttp.bom)
|
||||
implementation okhttpBom
|
||||
implementation libs.okhttp
|
||||
implementation libs.okhttp.coroutines
|
||||
implementation libs.okhttp.logging
|
||||
implementation libs.okhttp.sse
|
||||
|
||||
testImplementation libs.junit
|
||||
androidTestImplementation libs.androidx.junit
|
||||
androidTestImplementation libs.androidx.espresso.core
|
||||
@@ -105,6 +153,7 @@ cargo {
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
apiVersion = KotlinVersion.KOTLIN_2_1
|
||||
languageVersion = KotlinVersion.KOTLIN_2_1
|
||||
jvmTarget = JvmTarget.JVM_21
|
||||
}
|
||||
}
|
||||
@@ -125,7 +174,7 @@ android.applicationVariants.configureEach { variant ->
|
||||
def t = tasks.register("generate${variant.name.capitalize()}UniFFIBindings", Exec) {
|
||||
workingDir "${project.projectDir}"
|
||||
// Runs the bindings generation, note that you must have uniffi-bindgen installed and in your PATH environment variable
|
||||
commandLine "cargo", 'run', '--bin', 'uniffi-bindgen', 'generate', '--library', "${buildDir}/rustJniLibs/android/arm64-v8a/libaffine_mobile_native.so", '--language', 'kotlin', '--out-dir', "${project.projectDir}/src/main/java"
|
||||
commandLine 'cargo', 'run', '--bin', 'uniffi-bindgen', 'generate', '--library', "${buildDir}/rustJniLibs/android/arm64-v8a/libaffine_mobile_native.so", '--language', 'kotlin', '--out-dir', "${project.projectDir}/src/main/java"
|
||||
dependsOn("cargoBuild")
|
||||
}
|
||||
variant.javaCompileProvider.get().dependsOn(t)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".AffineApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -16,12 +17,12 @@
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -30,12 +31,22 @@
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="affine" android:host="authentication" android:pathPattern=".*"/>
|
||||
|
||||
<data
|
||||
android:host="authentication"
|
||||
android:pathPattern=".*"
|
||||
android:scheme="affine" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ai.AIActivity"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
@@ -43,7 +54,7 @@
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltAndroidApp
|
||||
class AffineApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||
CapacitorConfig.init(baseContext)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object CapacitorConfig {
|
||||
|
||||
@Serializable
|
||||
private data class Config(val affineVersion: String)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private lateinit var config: Config
|
||||
|
||||
fun init(context: Context) {
|
||||
val configJson = context.assets.open("capacitor.config.json")
|
||||
.bufferedReader()
|
||||
.readLines()
|
||||
.reduce { acc, s ->
|
||||
acc.trim().plus(s.trim())
|
||||
}
|
||||
config = json.decodeFromString(configJson)
|
||||
}
|
||||
|
||||
fun getAffineVersion() = config.affineVersion
|
||||
}
|
||||
@@ -3,31 +3,40 @@ package app.affine.pro
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.updateMargins
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.affine.pro.ai.AIActivity
|
||||
import app.affine.pro.plugin.AIButtonPlugin
|
||||
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.utils.dp
|
||||
import com.getcapacitor.BridgeActivity
|
||||
import com.getcapacitor.plugin.CapacitorCookies
|
||||
import com.getcapacitor.plugin.CapacitorHttp
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugin.Callback,
|
||||
View.OnClickListener {
|
||||
|
||||
@Inject
|
||||
lateinit var webRepo: WebRepo
|
||||
|
||||
init {
|
||||
registerPlugins(
|
||||
listOf(
|
||||
AffineThemePlugin::class.java,
|
||||
AIButtonPlugin::class.java,
|
||||
CapacitorHttp::class.java,
|
||||
CapacitorCookies::class.java,
|
||||
AuthPlugin::class.java,
|
||||
HashCashPlugin::class.java,
|
||||
NbStorePlugin::class.java,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -75,6 +84,10 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugi
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
Toast.makeText(this, "TODO: Start AI chat~", Toast.LENGTH_SHORT).show()
|
||||
lifecycleScope.launch {
|
||||
webRepo.init(bridge)
|
||||
AIActivity.open(this@MainActivity)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package app.affine.pro.ai
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.exclude
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.affine.pro.ai.chat.ChatViewModel
|
||||
import app.affine.pro.ai.chat.MessageUiState
|
||||
import app.affine.pro.ai.chat.ui.ChatAppBar
|
||||
import app.affine.pro.ai.chat.ui.Message
|
||||
import app.affine.pro.ai.chat.ui.UserInput
|
||||
import app.affine.pro.theme.AffineTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@AndroidEntryPoint
|
||||
class AIActivity : AppCompatActivity() {
|
||||
|
||||
private val viewModel by viewModels<ChatViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
|
||||
super.onCreate(savedInstanceState)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
|
||||
ViewCompat.onApplyWindowInsets(v, insets)
|
||||
}
|
||||
setContent {
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberLazyListState()
|
||||
val topBarState = rememberTopAppBarState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState)
|
||||
AffineTheme(isDarkTheme = true) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ChatAppBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
onBackClick = { finish() },
|
||||
)
|
||||
},
|
||||
contentWindowInsets = ScaffoldDefaults
|
||||
.contentWindowInsets
|
||||
.exclude(WindowInsets.navigationBars)
|
||||
.exclude(WindowInsets.ime),
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) { paddingValues ->
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
with(uiState) {
|
||||
when {
|
||||
this is MessageUiState -> LazyColumn(
|
||||
reverseLayout = true,
|
||||
state = scrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(
|
||||
items = messages,
|
||||
key = { it.id ?: "" },
|
||||
contentType = { it.role }
|
||||
) { message ->
|
||||
Message(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
UserInput(
|
||||
onMessageSent = { content ->
|
||||
Toast.makeText(context, "Not implemented.", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
// viewModel.sendMessage(content)
|
||||
},
|
||||
resetScroll = {
|
||||
scope.launch {
|
||||
scrollState.scrollToItem(0)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun open(activity: AppCompatActivity) {
|
||||
with(activity) {
|
||||
startActivity(Intent(this, AIActivity::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package app.affine.pro.ai
|
||||
|
||||
enum class Prompt(val value: String) {
|
||||
Summary("Summary"),
|
||||
ExplainThis("Explain this"),
|
||||
WriteAnArticleAboutThis("Write an article about this"),
|
||||
WriteATwitterAboutThis("Write a twitter about this"),
|
||||
WriteAPoemAboutThis("Write a poem about this"),
|
||||
WriteABlogPostAboutThis("Write a blog post about this"),
|
||||
WriteOutline("Write outline"),
|
||||
ChangeToneTo("Change tone to"),
|
||||
ImproveWritingForIt("Improve writing for it"),
|
||||
ImproveGrammarForIt("Improve grammar for it"),
|
||||
FixSpellingForIt("Fix spelling for it"),
|
||||
CreateHeadings("Create headings"),
|
||||
MakeItLonger("Make it longer"),
|
||||
MakeItShorter("Make it shorter"),
|
||||
ContinueWriting("Continue writing"),
|
||||
ChatWithAFFiNEAI("Chat With AFFiNE AI"),
|
||||
SearchWithAFFiNEAI("Search With AFFiNE AI"),
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package app.affine.pro.ai.chat
|
||||
|
||||
import com.affine.pro.graphql.GetCopilotHistoriesQuery
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
sealed class ChatUiState
|
||||
|
||||
data class MessageUiState(
|
||||
val messages: List<ChatMessage>
|
||||
) : ChatUiState()
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String?,
|
||||
val role: Role,
|
||||
val content: String,
|
||||
val createAt: Instant,
|
||||
) {
|
||||
enum class Role(val value: String) {
|
||||
User("user"),
|
||||
AI("assistant");
|
||||
|
||||
companion object {
|
||||
fun fromValue(role: String): Role {
|
||||
return entries.first { it.value == role }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(message: GetCopilotHistoriesQuery.Message) = ChatMessage(
|
||||
id = message.id,
|
||||
role = Role.fromValue(message.role),
|
||||
content = message.content,
|
||||
createAt = message.createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
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(
|
||||
private val webRepo: WebRepo,
|
||||
private val graphQLRepo: GraphQLRepo,
|
||||
private val sseRepo: SSERepo,
|
||||
) : ViewModel() {
|
||||
|
||||
private lateinit var sessionId: String
|
||||
|
||||
private val _uiState: MutableStateFlow<ChatUiState> =
|
||||
MutableStateFlow(MessageUiState(emptyList()))
|
||||
|
||||
val uiState: StateFlow<ChatUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
sessionId = graphQLRepo.createCopilotSession(
|
||||
workspaceId = webRepo.workspaceId(),
|
||||
docId = webRepo.docId(),
|
||||
).getOrElse {
|
||||
Timber.d("Create session failed")
|
||||
return@launch
|
||||
}
|
||||
Timber.d("Create session: $sessionId")
|
||||
val historyMessages = graphQLRepo.getCopilotHistories(
|
||||
workspaceId = webRepo.workspaceId(),
|
||||
docId = webRepo.docId(),
|
||||
sessionId = sessionId,
|
||||
).getOrDefault(emptyList()).map {
|
||||
ChatMessage.from(it)
|
||||
}.sortedByDescending {
|
||||
it.createAt
|
||||
}
|
||||
_uiState.value = MessageUiState(historyMessages)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(message: String) {
|
||||
val sendMessage = suspend {
|
||||
graphQLRepo.createCopilotMessage(
|
||||
sessionId = sessionId,
|
||||
message = message,
|
||||
).onSuccess { messageId ->
|
||||
Timber.d("send message: $messageId")
|
||||
sseRepo.messageStream(sessionId, messageId)
|
||||
.onEach {
|
||||
Timber.d("$coroutineContext")
|
||||
Timber.d("on message: ${it.getOrNull()}")
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.onEach {
|
||||
Timber.d("$coroutineContext")
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
if (!this@ChatViewModel::sessionId.isInitialized) {
|
||||
graphQLRepo.getCopilotSession(
|
||||
workspaceId = webRepo.workspaceId(),
|
||||
docId = webRepo.docId(),
|
||||
).onSuccess { id ->
|
||||
sessionId = id
|
||||
Timber.d("Create session: $id")
|
||||
sendMessage()
|
||||
}.onFailure {
|
||||
Timber.e(it, "Create session failed.")
|
||||
}
|
||||
} else {
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package app.affine.pro.ai.chat.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.affine.pro.components.AffineAppBar
|
||||
import app.affine.pro.components.AffineDropMenu
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
onBackClick: () -> Unit = { },
|
||||
onClearHistory: () -> Unit = { },
|
||||
onSaveAsChatBlock: () -> Unit = { },
|
||||
) {
|
||||
AffineAppBar(
|
||||
modifier = modifier,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onNavIconPressed = onBackClick,
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.clickable { },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Chat with AI")
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
AffineDropMenu(
|
||||
icon = { Icon(Icons.Default.MoreHoriz, contentDescription = "More actions") },
|
||||
menuItems = {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Clear history") },
|
||||
trailingIcon = { Icon(Icons.Default.Delete, contentDescription = null) },
|
||||
onClick = onClearHistory,
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save as chat block") },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
Icons.Default.ChatBubble,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = onSaveAsChatBlock,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package app.affine.pro.ai.chat.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.affine.pro.R
|
||||
import app.affine.pro.ai.chat.ChatMessage
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun Message(message: ChatMessage) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.let {
|
||||
if (message.role == ChatMessage.Role.User) {
|
||||
it.background(
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_ai),
|
||||
contentDescription = null,
|
||||
tint = Color(0XFF1E96EB),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = when (message.role) {
|
||||
ChatMessage.Role.User -> "You"
|
||||
ChatMessage.Role.AI -> "Affine AI"
|
||||
},
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = message.content,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UserMessagePreview() {
|
||||
Column {
|
||||
Message(
|
||||
ChatMessage(
|
||||
id = null,
|
||||
role = ChatMessage.Role.User,
|
||||
content = "Feel free to input any text and see how AI ABC responds. Give it a go!",
|
||||
createAt = Clock.System.now(),
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Message(
|
||||
ChatMessage(
|
||||
id = null,
|
||||
role = ChatMessage.Role.AI,
|
||||
content = "Go ahead and type in any message to see how our AI system will reply. Try it out!",
|
||||
createAt = Clock.System.now(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package app.affine.pro.ai.chat.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.outlined.CameraAlt
|
||||
import androidx.compose.material.icons.outlined.InsertPhoto
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyKey
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.affine.pro.theme.AffineTheme
|
||||
|
||||
enum class InputSelector {
|
||||
NONE,
|
||||
CAMERA,
|
||||
PICTURE
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UserInputPreview() {
|
||||
AffineTheme(isDarkTheme = true) {
|
||||
UserInput(onMessageSent = {})
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun UserInput(
|
||||
onMessageSent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
resetScroll: () -> Unit = {},
|
||||
) {
|
||||
var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val dismissKeyboard = {
|
||||
currentInputSelector = InputSelector.NONE
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
|
||||
if (currentInputSelector != InputSelector.NONE) {
|
||||
BackHandler(onBack = { dismissKeyboard() })
|
||||
}
|
||||
|
||||
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue())
|
||||
}
|
||||
|
||||
var textFieldFocusState by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 2.dp,
|
||||
shape = RoundedCornerShape(16.dp, 16.dp, 0.dp, 0.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
UserInputText(
|
||||
textFieldValue = textState,
|
||||
onTextChanged = { textState = it },
|
||||
keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState,
|
||||
onTextFieldFocused = { focused ->
|
||||
if (focused) {
|
||||
currentInputSelector = InputSelector.NONE
|
||||
resetScroll()
|
||||
}
|
||||
textFieldFocusState = focused
|
||||
},
|
||||
onMessageSent = {
|
||||
onMessageSent(textState.text)
|
||||
textState = TextFieldValue()
|
||||
resetScroll()
|
||||
},
|
||||
focusState = textFieldFocusState
|
||||
)
|
||||
UserInputSelector(
|
||||
onSelectorChange = { currentInputSelector = it },
|
||||
sendMessageEnabled = textState.text.isNotBlank(),
|
||||
onMessageSent = {
|
||||
onMessageSent(textState.text)
|
||||
textState = TextFieldValue()
|
||||
resetScroll()
|
||||
dismissKeyboard()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserInputSelector(
|
||||
onSelectorChange: (InputSelector) -> Unit,
|
||||
sendMessageEnabled: Boolean,
|
||||
onMessageSent: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(44.dp)
|
||||
.wrapContentHeight()
|
||||
.padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(24.dp),
|
||||
onClick = { onSelectorChange(InputSelector.CAMERA) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.CameraAlt,
|
||||
contentDescription = "Camera",
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(24.dp),
|
||||
onClick = { onSelectorChange(InputSelector.PICTURE) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.InsertPhoto,
|
||||
contentDescription = "Picture",
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
VerticalDivider(modifier = Modifier.height(10.dp))
|
||||
|
||||
Checkbox(
|
||||
modifier = Modifier.scale(0.8f),
|
||||
checked = true,
|
||||
enabled = false,
|
||||
onCheckedChange = {},
|
||||
)
|
||||
|
||||
Text(text = "Embed this doc")
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// Send button
|
||||
IconButton(
|
||||
modifier = Modifier.size(24.dp),
|
||||
enabled = sendMessageEnabled,
|
||||
onClick = onMessageSent,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.Send,
|
||||
contentDescription = "Send message",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val KeyboardShownKey = SemanticsPropertyKey<Boolean>("KeyboardShownKey")
|
||||
var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
private fun UserInputText(
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
onTextChanged: (TextFieldValue) -> Unit,
|
||||
textFieldValue: TextFieldValue,
|
||||
keyboardShown: Boolean,
|
||||
onTextFieldFocused: (Boolean) -> Unit,
|
||||
onMessageSent: (String) -> Unit,
|
||||
focusState: Boolean
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(66.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
|
||||
UserInputTextField(
|
||||
textFieldValue,
|
||||
onTextChanged,
|
||||
onTextFieldFocused,
|
||||
keyboardType,
|
||||
focusState,
|
||||
onMessageSent,
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = "Text Input"
|
||||
keyboardShownProperty = keyboardShown
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.UserInputTextField(
|
||||
textFieldValue: TextFieldValue,
|
||||
onTextChanged: (TextFieldValue) -> Unit,
|
||||
onTextFieldFocused: (Boolean) -> Unit,
|
||||
keyboardType: KeyboardType,
|
||||
focusState: Boolean,
|
||||
onMessageSent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var lastFocusState by remember { mutableStateOf(false) }
|
||||
val color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { onTextChanged(it) },
|
||||
modifier = modifier
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.align(Alignment.CenterStart)
|
||||
.onFocusChanged { state ->
|
||||
if (lastFocusState != state.isFocused) {
|
||||
onTextFieldFocused(state.isFocused)
|
||||
}
|
||||
lastFocusState = state.isFocused
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
imeAction = ImeAction.Send
|
||||
),
|
||||
keyboardActions = KeyboardActions {
|
||||
if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text)
|
||||
},
|
||||
cursorBrush = SolidColor(color),
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(color = color),
|
||||
)
|
||||
|
||||
if (textFieldValue.text.isEmpty() && !focusState) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
text = "Feel free to input any text and see how AI responds. Give it a go!",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package app.affine.pro.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBackIos
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.affine.pro.theme.AffineTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AffineAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
onNavIconPressed: () -> Unit = { },
|
||||
title: @Composable () -> Unit,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = modifier,
|
||||
actions = actions,
|
||||
title = title,
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onNavIconPressed
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.ArrowBackIos,
|
||||
contentDescription = "Back",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AffineDropMenu(
|
||||
icon: @Composable () -> Unit,
|
||||
menuItems: @Composable ColumnScope.() -> Unit = {}
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { expanded = !expanded }) {
|
||||
icon()
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
menuItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun AffineAppBarPreview() {
|
||||
AffineTheme {
|
||||
AffineAppBar(
|
||||
title = { Text("Preview!") },
|
||||
actions = {
|
||||
AffineDropMenu(
|
||||
icon = {
|
||||
Icon(Icons.Default.MoreHoriz, contentDescription = "Actions")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun AffineAppBarPreviewDark() {
|
||||
AffineTheme(isDarkTheme = true) {
|
||||
AffineAppBar(
|
||||
title = { Text("Preview!") },
|
||||
actions = {
|
||||
AffineDropMenu(
|
||||
icon = {
|
||||
Icon(Icons.Default.MoreHoriz, contentDescription = "Actions")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,17 @@ class AIButtonPlugin : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun present(call: PluginCall) {
|
||||
(activity as? Callback)?.present()
|
||||
call.resolve()
|
||||
launch {
|
||||
(activity as? Callback)?.present()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun dismiss(call: PluginCall) {
|
||||
(activity as? Callback)?.dismiss()
|
||||
call.resolve()
|
||||
launch {
|
||||
(activity as? Callback)?.dismiss()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import app.affine.pro.CapacitorConfig
|
||||
import app.affine.pro.service.CookieStore
|
||||
import app.affine.pro.service.OkHttp
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import org.json.JSONObject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@CapacitorPlugin(name = "Auth")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun signOut(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
val request = Request.Builder()
|
||||
.url("$endpoint/api/auth/sign-out")
|
||||
.header("x-affine-version", CapacitorConfig.getAffineVersion())
|
||||
.get()
|
||||
.build()
|
||||
OkHttp.client.newCall(request).executeAsync().use { response ->
|
||||
if (response.code >= 400) {
|
||||
call.reject(response.body.string())
|
||||
return@launch
|
||||
}
|
||||
call.resolve(JSObject().put("ok", true))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to sign out, $e", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import uniffi.affine_mobile_native.hashcashMint
|
||||
|
||||
@CapacitorPlugin(name = "HashCash")
|
||||
class HashCashPlugin : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun hash(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
val challenge = call.getString("challenge") ?: ""
|
||||
val bits = call.getInt("bits") ?: 20
|
||||
call.resolve(JSObject().apply {
|
||||
put("value", hashcashMint(resource = challenge, bits = bits.toUInt()))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
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.DocRecord
|
||||
import uniffi.affine_mobile_native.SetBlob
|
||||
import uniffi.affine_mobile_native.newDocStoragePool
|
||||
|
||||
@CapacitorPlugin(name = "NbStoreDocStorage")
|
||||
class NbStorePlugin : Plugin() {
|
||||
|
||||
private val docStoragePool by lazy {
|
||||
newDocStoragePool()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun connect(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val spaceId = call.getStringEnsure("spaceId")
|
||||
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.")
|
||||
return@launch
|
||||
}
|
||||
val peerDir = appStoragePath.resolve("workspaces")
|
||||
.resolve(spaceType)
|
||||
.resolve(
|
||||
peer.replace(Regex("[/!@#$%^&*()+~`\"':;,?<>|]"), "_")
|
||||
.replace(Regex("_+"), "_")
|
||||
.replace(Regex("_+$"), "")
|
||||
)
|
||||
Timber.d("connecting nbstore... peerDir[$peerDir]")
|
||||
peerDir.mkdirs()
|
||||
val db = peerDir.resolve("$spaceId.db")
|
||||
docStoragePool.connect(id, db.path)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to connect storage", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun disconnect(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
docStoragePool.disconnect(universalId = id)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to disconnect, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setSpaceId(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val spaceId = call.getStringEnsure("spaceId")
|
||||
docStoragePool.setSpaceId(universalId = id, spaceId = spaceId)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set space id, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun pushUpdate(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val data = call.getStringEnsure("data")
|
||||
val timestamp = docStoragePool.pushUpdate(
|
||||
universalId = id,
|
||||
docId = docId,
|
||||
update = data
|
||||
)
|
||||
call.resolve(JSObject().put("timestamp", timestamp))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to push update, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDocSnapshot(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val record = docStoragePool.getDocSnapshot(universalId = id, docId = docId)
|
||||
record?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("bin", it.bin)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get doc snapshot, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setDocSnapshot(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val bin = call.getStringEnsure("bin")
|
||||
val timestamp = call.getLongEnsure("timestamp")
|
||||
val success = docStoragePool.setDocSnapshot(
|
||||
universalId = id,
|
||||
snapshot = DocRecord(docId, bin, timestamp)
|
||||
)
|
||||
call.resolve(JSObject().put("success", success))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set doc snapshot, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDocUpdates(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val updates = docStoragePool.getDocUpdates(universalId = id, docId = docId)
|
||||
val mapped = JSArray(updates.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
.put("bin", it.bin)
|
||||
})
|
||||
call.resolve(JSObject().put("updates", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get doc updates, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun markUpdatesMerged(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val times = call.getListEnsure<Long>("timestamps")
|
||||
val count = docStoragePool.markUpdatesMerged(
|
||||
universalId = id,
|
||||
docId = docId,
|
||||
updates = times
|
||||
)
|
||||
call.resolve(JSObject().put("count", count))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to mark updates merged, ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteDoc(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
docStoragePool.deleteDoc(universalId = id, docId = docId)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to delete doc: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDocClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val after = call.getLong("after")
|
||||
val docClocks = docStoragePool.getDocClocks(
|
||||
universalId = id,
|
||||
after = after,
|
||||
)
|
||||
val mapped = JSArray(docClocks.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
})
|
||||
call.resolve(JSObject().put("clocks", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get doc clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDocClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val docClock = docStoragePool.getDocClock(universalId = id, docId = docId)
|
||||
docClock?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get doc clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getBlob(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val key = call.getStringEnsure("key")
|
||||
val blob = docStoragePool.getBlob(universalId = id, key = key)
|
||||
blob?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("key", it.key)
|
||||
.put("data", it.data)
|
||||
.put("mime", it.mime)
|
||||
.put("size", it.size)
|
||||
.put("createdAt", it.createdAt)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get blob: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setBlob(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val key = call.getStringEnsure("key")
|
||||
val data = call.getStringEnsure("data")
|
||||
val mime = call.getStringEnsure("mime")
|
||||
docStoragePool.setBlob(universalId = id, blob = SetBlob(key, data, mime))
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set blob: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteBlob(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val key = call.getStringEnsure("key")
|
||||
val permanently = call.getBoolean("permanently") ?: false
|
||||
docStoragePool.deleteBlob(universalId = id, key = key, permanently = permanently)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to delete blob: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun releaseBlobs(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
docStoragePool.releaseBlobs(universalId = id)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to release blobs: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun listBlobs(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val blobs = docStoragePool.listBlobs(universalId = id)
|
||||
val mapped = JSArray(blobs.map {
|
||||
JSObject()
|
||||
.put("key", it.key)
|
||||
.put("size", it.size)
|
||||
.put("mime", it.mime)
|
||||
.put("createdAt", it.createdAt)
|
||||
})
|
||||
call.resolve(JSObject().put("blobs", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to list blobs: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerRemoteClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val clocks = docStoragePool.getPeerRemoteClocks(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
)
|
||||
val mapped = JSArray(clocks.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
})
|
||||
call.resolve(JSObject().put("clocks", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer remote clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerRemoteClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val clock = docStoragePool.getPeerRemoteClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
)
|
||||
clock?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer remote clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPeerRemoteClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val timestamp = call.getLongEnsure("timestamp")
|
||||
docStoragePool.setPeerRemoteClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
clock = timestamp,
|
||||
)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set peer remote clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerPulledRemoteClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val clocks = docStoragePool.getPeerPulledRemoteClocks(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
)
|
||||
val mapped = JSArray(clocks.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
})
|
||||
call.resolve(JSObject().put("clocks", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer pulled remote clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerPulledRemoteClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val clock = docStoragePool.getPeerPulledRemoteClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
)
|
||||
clock?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer pulled remote clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPeerPulledRemoteClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val timestamp = call.getLongEnsure("timestamp")
|
||||
docStoragePool.setPeerPulledRemoteClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
clock = timestamp,
|
||||
)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set peer pulled remote clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerPushedClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val clocks = docStoragePool.getPeerPushedClocks(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
)
|
||||
val mapped = JSArray(clocks.map {
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
})
|
||||
call.resolve(JSObject().put("clocks", mapped))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer pushed clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getPeerPushedClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val clock = docStoragePool.getPeerPushedClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
)
|
||||
clock?.let {
|
||||
call.resolve(
|
||||
JSObject()
|
||||
.put("docId", it.docId)
|
||||
.put("timestamp", it.timestamp)
|
||||
)
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get peer pushed clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPeerPushedClock(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val docId = call.getStringEnsure("docId")
|
||||
val timestamp = call.getLongEnsure("timestamp")
|
||||
docStoragePool.setPeerPushedClock(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
docId = docId,
|
||||
clock = timestamp,
|
||||
)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set peer pushed clock: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getBlobUploadedAt(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val blobId = call.getStringEnsure("blobId")
|
||||
val uploadedAt = docStoragePool.getBlobUploadedAt(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
blobId = blobId,
|
||||
)
|
||||
uploadedAt?.let {
|
||||
call.resolve(JSObject().put("uploadedAt", it))
|
||||
} ?: call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to get blob uploaded: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setBlobUploadedAt(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
val peer = call.getStringEnsure("peer")
|
||||
val blobId = call.getStringEnsure("blobId")
|
||||
val uploadedAt = call.getLongEnsure("uploadedAt")
|
||||
docStoragePool.setBlobUploadedAt(
|
||||
universalId = id,
|
||||
peer = peer,
|
||||
blobId = blobId,
|
||||
uploadedAt = uploadedAt,
|
||||
)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to set blob uploaded: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun clearClocks(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val id = call.getStringEnsure("id")
|
||||
docStoragePool.clearClocks(universalId = id)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to clear clocks: ${e.message}", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
fun Plugin.launch(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
) = activity?.lifecycleScope?.launch(context, start, block)
|
||||
|
||||
fun PluginCall.getStringEnsure(key: String): String {
|
||||
return getString(key) ?: throw IllegalArgumentException("Missing $key parameter")
|
||||
}
|
||||
|
||||
inline fun <reified T> PluginCall.getListEnsure(key: String): List<T> {
|
||||
return getArray(key)?.toList() ?: throw IllegalArgumentException("Missing $key parameter")
|
||||
}
|
||||
|
||||
fun PluginCall.getLongEnsure(key: String): Long {
|
||||
return getLong(key) ?: throw IllegalArgumentException("Missing $key parameter")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package app.affine.pro.repo
|
||||
|
||||
import app.affine.pro.ai.Prompt
|
||||
import app.affine.pro.service.GraphQLClient
|
||||
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.api.Optional
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GraphQLRepo @Inject constructor(
|
||||
private val client: GraphQLClient
|
||||
) {
|
||||
|
||||
suspend fun getCopilotSession(workspaceId: String, docId: String) = client.query(
|
||||
GetCopilotSessionsQuery(
|
||||
workspaceId = workspaceId,
|
||||
docId = Optional.present(docId)
|
||||
)
|
||||
).mapCatching { data ->
|
||||
data.currentUser?.copilot?.sessions?.firstOrNull()?.id ?: error(ERROR_NULL_SESSION_ID)
|
||||
}
|
||||
|
||||
suspend fun createCopilotSession(
|
||||
workspaceId: String,
|
||||
docId: String,
|
||||
prompt: Prompt = Prompt.ChatWithAFFiNEAI
|
||||
) = client.mutation(
|
||||
CreateCopilotSessionMutation(
|
||||
CreateChatSessionInput(
|
||||
docId = docId,
|
||||
workspaceId = workspaceId,
|
||||
promptName = prompt.value
|
||||
)
|
||||
)
|
||||
).mapCatching { data ->
|
||||
data.createCopilotSession
|
||||
}
|
||||
|
||||
suspend fun getCopilotHistories(
|
||||
workspaceId: String,
|
||||
docId: String,
|
||||
sessionId: String,
|
||||
) = client.query(
|
||||
GetCopilotHistoriesQuery(
|
||||
workspaceId = workspaceId,
|
||||
docId = Optional.present(docId),
|
||||
)
|
||||
).mapCatching { data ->
|
||||
data.currentUser?.copilot?.histories?.firstOrNull { history ->
|
||||
history.sessionId == sessionId
|
||||
}?.messages ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun createCopilotMessage(
|
||||
sessionId: String,
|
||||
message: String,
|
||||
) = client.mutation(CreateCopilotMessageMutation(
|
||||
CreateChatMessageInput(
|
||||
sessionId = sessionId,
|
||||
content = Optional.present(message)
|
||||
)
|
||||
)).mapCatching { data ->
|
||||
data.createCopilotMessage
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ERROR_NULL_SESSION_ID = "null session id."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package app.affine.pro.repo
|
||||
|
||||
import app.affine.pro.BuildConfig
|
||||
import app.affine.pro.service.OkHttp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.sse.EventSource
|
||||
import okhttp3.sse.EventSourceListener
|
||||
import okhttp3.sse.EventSources
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SSERepo @Inject constructor() {
|
||||
|
||||
fun messageStream(sessionId: String, messageId: String) =
|
||||
"${BuildConfig.BASE_URL}/api/copilot/chat/$sessionId/stream?messageId=$messageId".eventSource()
|
||||
|
||||
data class Event(val id: String?, val type: String?, val data: String)
|
||||
|
||||
private val factory = EventSources.createFactory(OkHttp.client)
|
||||
|
||||
private fun String.eventSource(): Flow<Result<Event>> {
|
||||
val request = Request.Builder()
|
||||
.get()
|
||||
.url(this)
|
||||
.build()
|
||||
return callbackFlow<Result<Event>> {
|
||||
factory.newEventSource(request, object : EventSourceListener() {
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
channel.close()
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
eventSource: EventSource,
|
||||
id: String?,
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
trySendBlocking(Result.success(Event(id, type, data)))
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
eventSource: EventSource,
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
trySendBlocking(Result.failure(t ?: UnknownError("Unknown sse error.")))
|
||||
channel.close(t)
|
||||
}
|
||||
})
|
||||
awaitClose()
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package app.affine.pro.service
|
||||
|
||||
import app.affine.pro.BuildConfig
|
||||
import app.affine.pro.service.interceptor.CookieInterceptor
|
||||
import com.apollographql.apollo.ApolloClient
|
||||
import com.apollographql.apollo.api.ApolloResponse
|
||||
import com.apollographql.apollo.api.Mutation
|
||||
import com.apollographql.apollo.api.Query
|
||||
import com.apollographql.apollo.api.Subscription
|
||||
|
||||
object AffineClient {
|
||||
|
||||
private val _client: ApolloClient by lazy {
|
||||
ApolloClient.Builder().serverUrl(BuildConfig.BASE_URL)
|
||||
.addHttpInterceptor(CookieInterceptor)
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun <D : Query.Data> query(query: Query<D>): ApolloResponse<D> {
|
||||
return _client.query(query).execute()
|
||||
}
|
||||
|
||||
suspend fun <D : Mutation.Data> mutation(mutation: Mutation<D>): ApolloResponse<D> {
|
||||
return _client.mutation(mutation).execute()
|
||||
}
|
||||
|
||||
suspend fun <D : Subscription.Data> subscription(subscription: Subscription<D>): ApolloResponse<D> {
|
||||
return _client.subscription(subscription).execute()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package app.affine.pro.service
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object OkHttp {
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.cookieJar(object : CookieJar {
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val cookies = CookieStore.getCookies(url.host)
|
||||
Timber.d("load cookies: [ url = $url, cookies = $cookies]")
|
||||
return cookies
|
||||
}
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
Timber.d("save cookies: [ url = $url, cookies = $cookies]")
|
||||
CookieStore.saveCookies(url.host, cookies)
|
||||
}
|
||||
})
|
||||
.addInterceptor(HttpLoggingInterceptor { msg ->
|
||||
Timber.tag("Affine-Network")
|
||||
Timber.d(msg)
|
||||
}.apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
})
|
||||
.build()
|
||||
|
||||
}
|
||||
|
||||
object CookieStore {
|
||||
|
||||
private val _cookies = ConcurrentHashMap<String, List<Cookie>>()
|
||||
|
||||
fun saveCookies(host: String, cookies: List<Cookie>) {
|
||||
_cookies[host] = cookies
|
||||
}
|
||||
|
||||
fun getCookies(host: String) = _cookies[host] ?: emptyList()
|
||||
|
||||
fun getCookie(url: String, name: String) = url.toUri().host
|
||||
?.let { _cookies[it] }
|
||||
?.find { cookie -> cookie.name == name }
|
||||
?.value
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package app.affine.pro.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
object Dark {
|
||||
val BackgroundPrimary = Color(0XFF000000)
|
||||
val InverseBackgroundPrimary = Color(0XFFFFFFFF)
|
||||
val IconPrimary = Color(0XFFF3F3F3)
|
||||
val Surface = Color(0XFF2A2A2A)
|
||||
val TextPrimary = Color(0XFFE6E6E6)
|
||||
}
|
||||
|
||||
object Light {
|
||||
val BackgroundPrimary = Color(0XFFFFFFFF)
|
||||
val InverseBackgroundPrimary = Color(0XFF000000)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package app.affine.pro.theme
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
val AffineDarkColorScheme = darkColorScheme(
|
||||
background = Dark.BackgroundPrimary,
|
||||
onSurface = Dark.TextPrimary,
|
||||
onSurfaceVariant = Dark.IconPrimary,
|
||||
surfaceContainer = Dark.Surface,
|
||||
surface = Dark.BackgroundPrimary,
|
||||
inverseSurface = Dark.InverseBackgroundPrimary,
|
||||
)
|
||||
val AffineLightColorScheme = lightColorScheme()
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
@Composable
|
||||
fun AffineTheme(
|
||||
isDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (isDarkTheme) AffineDarkColorScheme else AffineLightColorScheme,
|
||||
typography = AffineTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package app.affine.pro.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
|
||||
val AffineTypography = Typography()
|
||||
@@ -15,8 +15,8 @@
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">#FFFFFF</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -11,21 +11,29 @@ buildscript {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
dependencies {
|
||||
classpath libs.android.gradlePlugin
|
||||
classpath libs.android.gradle.plugin
|
||||
classpath libs.google.services
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
alias libs.plugins.gradle.versions
|
||||
alias libs.plugins.version.catalog.update
|
||||
alias libs.plugins.android.application apply false
|
||||
alias libs.plugins.android.library apply false
|
||||
alias libs.plugins.kotlin.android apply false
|
||||
alias libs.plugins.kotlin.parcelize apply false
|
||||
alias libs.plugins.kotlin.serialization apply false
|
||||
alias libs.plugins.compose apply false
|
||||
alias libs.plugins.ksp apply false
|
||||
alias libs.plugins.hilt apply false
|
||||
alias libs.plugins.rust.android apply false
|
||||
alias libs.plugins.apollo.android apply false
|
||||
alias(libs.plugins.jetbrains.kotlin.jvm) apply false
|
||||
alias libs.plugins.jetbrains.kotlin.jvm apply false
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
apply from: "${project.rootDir}/buildscripts/toml-updater-config.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
versionCatalogUpdate {
|
||||
sortByKey.set(true)
|
||||
|
||||
keep {
|
||||
// keep versions without any library or plugin reference
|
||||
keepUnusedVersions.set(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)
|
||||
}
|
||||
}
|
||||
|
||||
def isNonStable = { String version ->
|
||||
def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { it -> version.toUpperCase().contains(it) }
|
||||
def regex = /^[0-9,.v-]+(-r)?$/
|
||||
return !stableKeyword && !(version ==~ regex)
|
||||
}
|
||||
|
||||
tasks.named("dependencyUpdates").configure {
|
||||
resolutionStrategy {
|
||||
componentSelection {
|
||||
all {
|
||||
if (isNonStable(it.candidate.version) && !isNonStable(it.currentVersion)) {
|
||||
reject('Release candidate')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.9.1'
|
||||
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:amazon="http://schemas.amazon.com/apk/res/android">
|
||||
<application >
|
||||
<application android:usesCleartextTraffic="true">
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
@@ -20,3 +20,5 @@ org.gradle.jvmargs=-Xmx1536m
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
ksp.incremental.apt=true
|
||||
ksp.useKSP2=true
|
||||
|
||||
@@ -1,46 +1,101 @@
|
||||
[versions]
|
||||
androidGradlePlugin = "8.9.1"
|
||||
androidxEspressoCore = "3.6.1"
|
||||
androidxJunit = "1.2.1"
|
||||
apollo = "4.1.1"
|
||||
appcompat = "1.7.0"
|
||||
browser = "1.8.0"
|
||||
coordinatorLayout = "1.3.0"
|
||||
coreKtx = "1.16.0"
|
||||
coreSplashScreen = "1.0.1"
|
||||
googleServices = "4.4.2"
|
||||
jna = "5.17.0"
|
||||
junitVersion = "4.13.2"
|
||||
kotlin = "2.1.20"
|
||||
kotlinxCoroutines = "1.10.2"
|
||||
material = "1.12.0"
|
||||
material3 = "1.3.2"
|
||||
rustAndroid = "0.9.6"
|
||||
# @keep
|
||||
android-gradle-plugin = "8.9.1"
|
||||
androidx-activity-compose = "1.10.1"
|
||||
androidx-appcompat = "1.7.0"
|
||||
androidx-browser = "1.8.0"
|
||||
androidx-compose-bom = "2025.03.01"
|
||||
androidx-coordinatorlayout = "1.3.0"
|
||||
androidx-core-ktx = "1.15.0"
|
||||
androidx-core-splashscreen = "1.0.1"
|
||||
androidx-espresso-core = "3.6.1"
|
||||
androidx-junit = "1.2.1"
|
||||
androidx-lifecycle-compose = "2.8.7"
|
||||
androidx-material3 = "1.3.1"
|
||||
androidx-navigation = "2.8.9"
|
||||
apollo = "4.1.1"
|
||||
apollo-kotlin-adapters = "0.0.4"
|
||||
# @keep
|
||||
compileSdk = "35"
|
||||
google-services = "4.4.2"
|
||||
gradle-versions = "0.52.0"
|
||||
hilt = "2.56.1"
|
||||
hilt-ext = "1.2.0"
|
||||
jna = "5.17.0"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.1.20"
|
||||
kotlinx-coroutines = "1.10.1"
|
||||
kotlinx-datetime = "0.6.2"
|
||||
kotlinx-serialization-json = "1.8.0"
|
||||
ksp = "2.1.20-1.0.32"
|
||||
# @keep
|
||||
minSdk = "22"
|
||||
mozilla-rust-android = "0.9.6"
|
||||
okhttp = "5.0.0-alpha.14"
|
||||
# @keep
|
||||
targetSdk = "35"
|
||||
timber = "5.0.1"
|
||||
version-catalog-update = "0.8.5"
|
||||
|
||||
[libraries]
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }
|
||||
androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" }
|
||||
androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "coordinatorLayout" }
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashScreen" }
|
||||
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspressoCore" }
|
||||
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
||||
androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
|
||||
|
||||
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" }
|
||||
apollo-api = { module = "com.apollographql.apollo:apollo-api", version.ref = "apollo" }
|
||||
apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" }
|
||||
google-material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||
google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }
|
||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||
junit = { module = "junit:junit", version.ref = "junitVersion" }
|
||||
android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" }
|
||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
|
||||
androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" }
|
||||
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" }
|
||||
androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" }
|
||||
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" }
|
||||
androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" }
|
||||
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-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" }
|
||||
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-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-compose" }
|
||||
androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" }
|
||||
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-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-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-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" }
|
||||
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" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||
hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-ext" }
|
||||
hilt-ext-work = { module = "androidx.hilt:hilt-work", version.ref = "hilt-ext" }
|
||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||
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" }
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp" }
|
||||
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-sse = { module = "com.squareup.okhttp3:okhttp-sse" }
|
||||
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
||||
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
|
||||
apollo-android = { id = "com.apollographql.apollo", version.ref = "apollo" }
|
||||
android-application = { id = "com.android.application", 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" }
|
||||
compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
rust-android = { id = "org.mozilla.rust-android-gradle.rust-android", version.ref = "rustAndroid" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
rust-android = { id = "org.mozilla.rust-android-gradle.rust-android", version.ref = "mozilla-rust-android" }
|
||||
version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" }
|
||||
|
||||
@@ -5,14 +5,16 @@ plugins {
|
||||
|
||||
dependencies {
|
||||
implementation libs.apollo.api
|
||||
implementation libs.apollo.adapters.core
|
||||
implementation libs.apollo.adapters.kotlinx.datetime
|
||||
api libs.kotlinx.datetime
|
||||
}
|
||||
|
||||
apollo {
|
||||
service("affine") {
|
||||
srcDir("../../../../../common/graphql/src/graphql")
|
||||
schemaFiles.from("../../../../../backend/server/src/schema.gql")
|
||||
packageName.set("com.affine.pro.graphql")
|
||||
introspection {
|
||||
endpointUrl.set("https://app.affine.pro/graphql")
|
||||
schemaFile.set(file("src/main/graphql/affine/schema.graphqls"))
|
||||
}
|
||||
mapScalar("DateTime", "kotlinx.datetime.Instant", "com.apollographql.adapter.datetime.KotlinxInstantAdapter")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mutation CreateCopilotSession($options: CreateChatSessionInput!) {
|
||||
createCopilotSession(options: $options)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
ext {
|
||||
minSdkVersion = 22
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
minSdkVersion = libs.versions.minSdk.get().toInteger()
|
||||
compileSdkVersion = libs.versions.compileSdk.get().toInteger()
|
||||
targetSdkVersion = libs.versions.targetSdk.get().toInteger()
|
||||
kotlin_version = libs.versions.kotlin.get()
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
import { join } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(resolve(__dirname, './package.json'), 'utf-8')
|
||||
);
|
||||
|
||||
interface AppConfig {
|
||||
affineVersion: string;
|
||||
}
|
||||
|
||||
const config: CapacitorConfig & AppConfig = {
|
||||
appId: 'app.affine.pro',
|
||||
appName: 'AFFiNE',
|
||||
webDir: 'dist',
|
||||
affineVersion: packageJson.version,
|
||||
android: {
|
||||
path: 'App',
|
||||
buildOptions: {
|
||||
@@ -16,14 +26,25 @@ const config: CapacitorConfig = {
|
||||
releaseType: 'AAB',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
cleartext: true,
|
||||
},
|
||||
plugins: {
|
||||
CapacitorHttp: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
},
|
||||
CapacitorCookies: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.CAP_SERVER_URL) {
|
||||
Object.assign(config, {
|
||||
server: {
|
||||
url: process.env.CAP_SERVER_URL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"scripts": {
|
||||
"build": "affine bundle",
|
||||
"dev": "affine bundle --dev",
|
||||
"sync": "cap sync",
|
||||
"sync:dev": "CAP_SERVER_URL=http://10.0.2.2:8080 cap sync",
|
||||
"studio": "cap open android"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -25,6 +27,8 @@
|
||||
"@capgo/inappbrowser": "^7.1.0",
|
||||
"@sentry/react": "^9.2.0",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"async-call-rpc": "^6.4.2",
|
||||
"idb": "^8.0.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -6,9 +6,13 @@ import { router } from '@affine/core/mobile/router';
|
||||
import { configureCommonModules } from '@affine/core/modules';
|
||||
import { AIButtonProvider } from '@affine/core/modules/ai-button';
|
||||
import {
|
||||
AuthProvider,
|
||||
AuthService,
|
||||
DefaultServerService,
|
||||
ServerScope,
|
||||
ServerService,
|
||||
ServersService,
|
||||
ValidatorProvider,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { DocsService } from '@affine/core/modules/doc';
|
||||
import { GlobalContextService } from '@affine/core/modules/global-context';
|
||||
@@ -40,16 +44,19 @@ import { EdgeToEdge } from '@capawesome/capacitor-android-edge-to-edge-support';
|
||||
import { InAppBrowser } from '@capgo/inappbrowser';
|
||||
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
|
||||
import { OpClient } from '@toeverything/infra/op';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
|
||||
import { AffineTheme } from './plugins/affine-theme';
|
||||
import { AIButton } from './plugins/ai-button';
|
||||
import { Auth } from './plugins/auth';
|
||||
import { HashCash } from './plugins/hashcash';
|
||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
import { writeEndpointToken } from './proxy';
|
||||
|
||||
const storeManagerClient = new StoreManagerClient(
|
||||
new OpClient(new Worker(getWorkerUrl('nbstore')))
|
||||
);
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
window.addEventListener('beforeunload', () => {
|
||||
storeManagerClient.dispose();
|
||||
});
|
||||
@@ -137,6 +144,13 @@ framework.impl(VirtualKeyboardProvider, {
|
||||
},
|
||||
});
|
||||
|
||||
framework.impl(ValidatorProvider, {
|
||||
async validate(_challenge, resource) {
|
||||
const res = await HashCash.hash({ challenge: resource });
|
||||
return res.value;
|
||||
},
|
||||
});
|
||||
|
||||
framework.impl(AIButtonProvider, {
|
||||
presentAIButton: () => {
|
||||
return AIButton.present();
|
||||
@@ -146,6 +160,44 @@ framework.impl(AIButtonProvider, {
|
||||
},
|
||||
});
|
||||
|
||||
framework.scope(ServerScope).override(AuthProvider, resolver => {
|
||||
const serverService = resolver.get(ServerService);
|
||||
const endpoint = serverService.server.baseUrl;
|
||||
return {
|
||||
async signInMagicLink(email, linkToken, clientNonce) {
|
||||
const { token } = await Auth.signInMagicLink({
|
||||
endpoint,
|
||||
email,
|
||||
token: linkToken,
|
||||
clientNonce,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signInOauth(code, state, _provider, clientNonce) {
|
||||
const { token } = await Auth.signInOauth({
|
||||
endpoint,
|
||||
code,
|
||||
state,
|
||||
clientNonce,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
return {};
|
||||
},
|
||||
async signInPassword(credential) {
|
||||
const { token } = await Auth.signInPassword({
|
||||
endpoint,
|
||||
...credential,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
await Auth.signOut({
|
||||
endpoint,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ------ some apis for native ------
|
||||
(window as any).getCurrentServerBaseUrl = () => {
|
||||
const globalContextService = frameworkProvider.get(GlobalContextService);
|
||||
@@ -302,3 +354,35 @@ export function App() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function createStoreManagerClient() {
|
||||
const worker = new Worker(getWorkerUrl('nbstore.worker.js'));
|
||||
const { port1: nativeDBApiChannelServer, port2: nativeDBApiChannelClient } =
|
||||
new MessageChannel();
|
||||
AsyncCall<typeof NbStoreNativeDBApis>(NbStoreNativeDBApis, {
|
||||
channel: {
|
||||
on(listener) {
|
||||
const f = (e: MessageEvent<any>) => {
|
||||
listener(e.data);
|
||||
};
|
||||
nativeDBApiChannelServer.addEventListener('message', f);
|
||||
return () => {
|
||||
nativeDBApiChannelServer.removeEventListener('message', f);
|
||||
};
|
||||
},
|
||||
send(data) {
|
||||
nativeDBApiChannelServer.postMessage(data);
|
||||
},
|
||||
},
|
||||
log: false,
|
||||
});
|
||||
nativeDBApiChannelServer.start();
|
||||
worker.postMessage(
|
||||
{
|
||||
type: 'native-db-api-channel',
|
||||
port: nativeDBApiChannelClient,
|
||||
},
|
||||
[nativeDBApiChannelClient]
|
||||
);
|
||||
return new StoreManagerClient(new OpClient(worker));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import './setup';
|
||||
|
||||
import { Telemetry } from '@affine/core/components/telemetry';
|
||||
import { bindNativeDBApis } from '@affine/nbstore/sqlite';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { App } from './app';
|
||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
|
||||
bindNativeDBApis(NbStoreNativeDBApis);
|
||||
|
||||
function mountApp() {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
|
||||
@@ -1,22 +1,71 @@
|
||||
import '@affine/core/bootstrap/browser';
|
||||
import './setup-worker';
|
||||
|
||||
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
|
||||
import { cloudStorages } from '@affine/nbstore/cloud';
|
||||
import { idbStorages } from '@affine/nbstore/idb';
|
||||
import {
|
||||
cloudStorages,
|
||||
configureSocketAuthMethod,
|
||||
} from '@affine/nbstore/cloud';
|
||||
import { idbStoragesIndexerOnly } from '@affine/nbstore/idb';
|
||||
import {
|
||||
bindNativeDBApis,
|
||||
type NativeDBApis,
|
||||
sqliteStorages,
|
||||
} from '@affine/nbstore/sqlite';
|
||||
import {
|
||||
StoreManagerConsumer,
|
||||
type WorkerManagerOps,
|
||||
} from '@affine/nbstore/worker/consumer';
|
||||
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
const consumer = new StoreManagerConsumer([
|
||||
...idbStorages,
|
||||
import { readEndpointToken } from './proxy';
|
||||
|
||||
configureSocketAuthMethod((endpoint, cb) => {
|
||||
readEndpointToken(endpoint)
|
||||
.then(token => {
|
||||
cb({ token });
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
});
|
||||
|
||||
globalThis.addEventListener('message', e => {
|
||||
if (e.data.type === 'native-db-api-channel') {
|
||||
const port = e.ports[0] as MessagePort;
|
||||
const rpc = AsyncCall<NativeDBApis>(
|
||||
{},
|
||||
{
|
||||
channel: {
|
||||
on(listener) {
|
||||
const f = (e: MessageEvent<any>) => {
|
||||
listener(e.data);
|
||||
};
|
||||
port.addEventListener('message', f);
|
||||
return () => {
|
||||
port.removeEventListener('message', f);
|
||||
};
|
||||
},
|
||||
send(data) {
|
||||
port.postMessage(data);
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
bindNativeDBApis(rpc);
|
||||
port.start();
|
||||
}
|
||||
});
|
||||
|
||||
const consumer = new OpConsumer<WorkerManagerOps>(
|
||||
globalThis as MessageCommunicapable
|
||||
);
|
||||
|
||||
const storeManager = new StoreManagerConsumer([
|
||||
...idbStoragesIndexerOnly,
|
||||
...sqliteStorages,
|
||||
...broadcastChannelStorages,
|
||||
...cloudStorages,
|
||||
]);
|
||||
|
||||
const opConsumer = new OpConsumer<WorkerManagerOps>(
|
||||
globalThis as MessageCommunicapable
|
||||
);
|
||||
|
||||
consumer.bindConsumer(opConsumer);
|
||||
storeManager.bindConsumer(consumer);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface AuthPlugin {
|
||||
signInMagicLink(options: {
|
||||
endpoint: string;
|
||||
email: string;
|
||||
token: string;
|
||||
clientNonce?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signInOauth(options: {
|
||||
endpoint: string;
|
||||
code: string;
|
||||
state: string;
|
||||
clientNonce?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signInPassword(options: {
|
||||
endpoint: string;
|
||||
email: string;
|
||||
password: string;
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string }): Promise<void>;
|
||||
}
|
||||
8
packages/frontend/apps/android/src/plugins/auth/index.ts
Normal file
8
packages/frontend/apps/android/src/plugins/auth/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { AuthPlugin } from './definitions';
|
||||
|
||||
const Auth = registerPlugin<AuthPlugin>('Auth');
|
||||
|
||||
export * from './definitions';
|
||||
export { Auth };
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface HashCashPlugin {
|
||||
hash(options: {
|
||||
challenge: string;
|
||||
bits?: number;
|
||||
}): Promise<{ value: string }>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { HashCashPlugin } from './definitions';
|
||||
|
||||
const HashCash = registerPlugin<HashCashPlugin>('HashCash');
|
||||
|
||||
export * from './definitions';
|
||||
export { HashCash };
|
||||
@@ -0,0 +1,152 @@
|
||||
export interface Blob {
|
||||
key: string;
|
||||
// base64 encoded data
|
||||
data: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SetBlob {
|
||||
key: string;
|
||||
// base64 encoded data
|
||||
data: string;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
export interface ListedBlob {
|
||||
key: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface DocClock {
|
||||
docId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface NbStorePlugin {
|
||||
connect: (options: {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
spaceType: string;
|
||||
peer: string;
|
||||
}) => Promise<void>;
|
||||
disconnect: (options: { id: string }) => Promise<void>;
|
||||
|
||||
setSpaceId: (options: { id: string; spaceId: string }) => Promise<void>;
|
||||
pushUpdate: (options: {
|
||||
id: string;
|
||||
docId: string;
|
||||
data: string;
|
||||
}) => Promise<{ timestamp: number }>;
|
||||
getDocSnapshot: (options: { id: string; docId: string }) => Promise<
|
||||
| {
|
||||
docId: string;
|
||||
// base64 encoded data
|
||||
bin: string;
|
||||
timestamp: number;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
setDocSnapshot: (options: {
|
||||
id: string;
|
||||
docId: string;
|
||||
bin: string;
|
||||
timestamp: number;
|
||||
}) => Promise<{ success: boolean }>;
|
||||
getDocUpdates: (options: { id: string; docId: string }) => Promise<{
|
||||
updates: {
|
||||
docId: string;
|
||||
timestamp: number;
|
||||
// base64 encoded data
|
||||
bin: string;
|
||||
}[];
|
||||
}>;
|
||||
markUpdatesMerged: (options: {
|
||||
id: string;
|
||||
docId: string;
|
||||
timestamps: number[];
|
||||
}) => Promise<{ count: number }>;
|
||||
deleteDoc: (options: { id: string; docId: string }) => Promise<void>;
|
||||
getDocClocks: (options: { id: string; after?: number | null }) => Promise<{
|
||||
clocks: {
|
||||
docId: string;
|
||||
timestamp: number;
|
||||
}[];
|
||||
}>;
|
||||
getDocClock: (options: { id: string; docId: string }) => Promise<
|
||||
| {
|
||||
docId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
| undefined
|
||||
>;
|
||||
getBlob: (options: { id: string; key: string }) => Promise<Blob | null>;
|
||||
setBlob: (options: { id: string } & SetBlob) => Promise<void>;
|
||||
deleteBlob: (options: {
|
||||
id: string;
|
||||
key: string;
|
||||
permanently: boolean;
|
||||
}) => Promise<void>;
|
||||
releaseBlobs: (options: { id: string }) => Promise<void>;
|
||||
listBlobs: (options: { id: string }) => Promise<{ blobs: Array<ListedBlob> }>;
|
||||
getPeerRemoteClocks: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
}) => Promise<{ clocks: Array<DocClock> }>;
|
||||
getPeerRemoteClock: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
docId: string;
|
||||
}) => Promise<DocClock | null>;
|
||||
setPeerRemoteClock: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
docId: string;
|
||||
timestamp: number;
|
||||
}) => Promise<void>;
|
||||
getPeerPushedClocks: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
}) => Promise<{ clocks: Array<DocClock> }>;
|
||||
getPeerPushedClock: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
docId: string;
|
||||
}) => Promise<DocClock | null>;
|
||||
setPeerPushedClock: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
docId: string;
|
||||
timestamp: number;
|
||||
}) => Promise<void>;
|
||||
getPeerPulledRemoteClocks: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
}) => Promise<{ clocks: Array<DocClock> }>;
|
||||
getPeerPulledRemoteClock: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
docId: string;
|
||||
}) => Promise<DocClock | null>;
|
||||
setPeerPulledRemoteClock: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
docId: string;
|
||||
timestamp: number;
|
||||
}) => Promise<void>;
|
||||
getBlobUploadedAt: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
blobId: string;
|
||||
}) => Promise<{ uploadedAt: number | null }>;
|
||||
setBlobUploadedAt: (options: {
|
||||
id: string;
|
||||
peer: string;
|
||||
blobId: string;
|
||||
uploadedAt: number | null;
|
||||
}) => Promise<void>;
|
||||
clearClocks: (options: { id: string }) => Promise<void>;
|
||||
}
|
||||
339
packages/frontend/apps/android/src/plugins/nbstore/index.ts
Normal file
339
packages/frontend/apps/android/src/plugins/nbstore/index.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
base64ToUint8Array,
|
||||
uint8ArrayToBase64,
|
||||
} from '@affine/core/modules/workspace-engine';
|
||||
import {
|
||||
type BlobRecord,
|
||||
type DocClock,
|
||||
type DocRecord,
|
||||
type ListedBlobRecord,
|
||||
parseUniversalId,
|
||||
} from '@affine/nbstore';
|
||||
import { type NativeDBApis } from '@affine/nbstore/sqlite';
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { NbStorePlugin } from './definitions';
|
||||
|
||||
export * from './definitions';
|
||||
|
||||
export const NbStore = registerPlugin<NbStorePlugin>('NbStoreDocStorage');
|
||||
|
||||
export const NbStoreNativeDBApis: NativeDBApis = {
|
||||
connect: async function (id: string): Promise<void> {
|
||||
const { peer, type, id: spaceId } = parseUniversalId(id);
|
||||
return await NbStore.connect({ id, spaceId, spaceType: type, peer });
|
||||
},
|
||||
disconnect: function (id: string): Promise<void> {
|
||||
return NbStore.disconnect({ id });
|
||||
},
|
||||
pushUpdate: async function (
|
||||
id: string,
|
||||
docId: string,
|
||||
update: Uint8Array
|
||||
): Promise<Date> {
|
||||
const { timestamp } = await NbStore.pushUpdate({
|
||||
id,
|
||||
docId,
|
||||
data: await uint8ArrayToBase64(update),
|
||||
});
|
||||
return new Date(timestamp);
|
||||
},
|
||||
getDocSnapshot: async function (
|
||||
id: string,
|
||||
docId: string
|
||||
): Promise<DocRecord | null> {
|
||||
const snapshot = await NbStore.getDocSnapshot({ id, docId });
|
||||
return snapshot
|
||||
? {
|
||||
bin: base64ToUint8Array(snapshot.bin),
|
||||
docId: snapshot.docId,
|
||||
timestamp: new Date(snapshot.timestamp),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
setDocSnapshot: async function (
|
||||
id: string,
|
||||
snapshot: DocRecord
|
||||
): Promise<boolean> {
|
||||
const { success } = await NbStore.setDocSnapshot({
|
||||
id,
|
||||
docId: snapshot.docId,
|
||||
bin: await uint8ArrayToBase64(snapshot.bin),
|
||||
timestamp: snapshot.timestamp.getTime(),
|
||||
});
|
||||
return success;
|
||||
},
|
||||
getDocUpdates: async function (
|
||||
id: string,
|
||||
docId: string
|
||||
): Promise<DocRecord[]> {
|
||||
const { updates } = await NbStore.getDocUpdates({ id, docId });
|
||||
return updates.map(update => ({
|
||||
bin: base64ToUint8Array(update.bin),
|
||||
docId: update.docId,
|
||||
timestamp: new Date(update.timestamp),
|
||||
}));
|
||||
},
|
||||
markUpdatesMerged: async function (
|
||||
id: string,
|
||||
docId: string,
|
||||
updates: Date[]
|
||||
): Promise<number> {
|
||||
const { count } = await NbStore.markUpdatesMerged({
|
||||
id,
|
||||
docId,
|
||||
timestamps: updates.map(t => t.getTime()),
|
||||
});
|
||||
return count;
|
||||
},
|
||||
deleteDoc: async function (id: string, docId: string): Promise<void> {
|
||||
await NbStore.deleteDoc({
|
||||
id,
|
||||
docId,
|
||||
});
|
||||
},
|
||||
getDocClocks: async function (
|
||||
id: string,
|
||||
after?: Date | undefined | null
|
||||
): Promise<DocClock[]> {
|
||||
const clocks = (
|
||||
await NbStore.getDocClocks({
|
||||
id,
|
||||
after: after?.getTime(),
|
||||
})
|
||||
).clocks;
|
||||
return clocks.map(c => ({
|
||||
docId: c.docId,
|
||||
timestamp: new Date(c.timestamp),
|
||||
}));
|
||||
},
|
||||
getDocClock: async function (
|
||||
id: string,
|
||||
docId: string
|
||||
): Promise<DocClock | null> {
|
||||
const clock = await NbStore.getDocClock({
|
||||
id,
|
||||
docId,
|
||||
});
|
||||
return clock
|
||||
? {
|
||||
timestamp: new Date(clock.timestamp),
|
||||
docId: clock.docId,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
getBlob: async function (
|
||||
id: string,
|
||||
key: string
|
||||
): Promise<BlobRecord | null> {
|
||||
const record = await NbStore.getBlob({
|
||||
id,
|
||||
key,
|
||||
});
|
||||
return record
|
||||
? {
|
||||
data: base64ToUint8Array(record.data),
|
||||
key: record.key,
|
||||
mime: record.mime,
|
||||
createdAt: new Date(record.createdAt),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
setBlob: async function (id: string, blob: BlobRecord): Promise<void> {
|
||||
await NbStore.setBlob({
|
||||
id,
|
||||
data: await uint8ArrayToBase64(blob.data),
|
||||
key: blob.key,
|
||||
mime: blob.mime,
|
||||
});
|
||||
},
|
||||
deleteBlob: async function (
|
||||
id: string,
|
||||
key: string,
|
||||
permanently: boolean
|
||||
): Promise<void> {
|
||||
await NbStore.deleteBlob({
|
||||
id,
|
||||
key,
|
||||
permanently,
|
||||
});
|
||||
},
|
||||
releaseBlobs: async function (id: string): Promise<void> {
|
||||
await NbStore.releaseBlobs({
|
||||
id,
|
||||
});
|
||||
},
|
||||
listBlobs: async function (id: string): Promise<ListedBlobRecord[]> {
|
||||
const listed = await NbStore.listBlobs({
|
||||
id,
|
||||
});
|
||||
return listed.blobs.map(b => ({
|
||||
key: b.key,
|
||||
mime: b.mime,
|
||||
size: b.size,
|
||||
createdAt: new Date(b.createdAt),
|
||||
}));
|
||||
},
|
||||
getPeerRemoteClocks: async function (
|
||||
id: string,
|
||||
peer: string
|
||||
): Promise<DocClock[]> {
|
||||
const clocks = (
|
||||
await NbStore.getPeerRemoteClocks({
|
||||
id,
|
||||
peer,
|
||||
})
|
||||
).clocks;
|
||||
|
||||
return clocks.map(c => ({
|
||||
docId: c.docId,
|
||||
timestamp: new Date(c.timestamp),
|
||||
}));
|
||||
},
|
||||
getPeerRemoteClock: async function (id: string, peer: string, docId: string) {
|
||||
const clock = await NbStore.getPeerRemoteClock({
|
||||
id,
|
||||
peer,
|
||||
docId,
|
||||
});
|
||||
return clock
|
||||
? {
|
||||
docId: clock.docId,
|
||||
timestamp: new Date(clock.timestamp),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
setPeerRemoteClock: async function (
|
||||
id: string,
|
||||
peer: string,
|
||||
docId: string,
|
||||
clock: Date
|
||||
): Promise<void> {
|
||||
await NbStore.setPeerRemoteClock({
|
||||
id,
|
||||
peer,
|
||||
docId,
|
||||
timestamp: clock.getTime(),
|
||||
});
|
||||
},
|
||||
getPeerPulledRemoteClocks: async function (
|
||||
id: string,
|
||||
peer: string
|
||||
): Promise<DocClock[]> {
|
||||
const clocks = (
|
||||
await NbStore.getPeerPulledRemoteClocks({
|
||||
id,
|
||||
peer,
|
||||
})
|
||||
).clocks;
|
||||
return clocks.map(c => ({
|
||||
docId: c.docId,
|
||||
timestamp: new Date(c.timestamp),
|
||||
}));
|
||||
},
|
||||
getPeerPulledRemoteClock: async function (
|
||||
id: string,
|
||||
peer: string,
|
||||
docId: string
|
||||
) {
|
||||
const clock = await NbStore.getPeerPulledRemoteClock({
|
||||
id,
|
||||
peer,
|
||||
docId,
|
||||
});
|
||||
return clock
|
||||
? {
|
||||
docId: clock.docId,
|
||||
timestamp: new Date(clock.timestamp),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
setPeerPulledRemoteClock: async function (
|
||||
id: string,
|
||||
peer: string,
|
||||
docId: string,
|
||||
clock: Date
|
||||
): Promise<void> {
|
||||
await NbStore.setPeerPulledRemoteClock({
|
||||
id,
|
||||
peer,
|
||||
docId,
|
||||
timestamp: clock.getTime(),
|
||||
});
|
||||
},
|
||||
getPeerPushedClocks: async function (
|
||||
id: string,
|
||||
peer: string
|
||||
): Promise<DocClock[]> {
|
||||
const clocks = (
|
||||
await NbStore.getPeerPushedClocks({
|
||||
id,
|
||||
peer,
|
||||
})
|
||||
).clocks;
|
||||
return clocks.map(c => ({
|
||||
docId: c.docId,
|
||||
timestamp: new Date(c.timestamp),
|
||||
}));
|
||||
},
|
||||
getPeerPushedClock: async function (
|
||||
id: string,
|
||||
peer: string,
|
||||
docId: string
|
||||
): Promise<DocClock | null> {
|
||||
const clock = await NbStore.getPeerPushedClock({
|
||||
id,
|
||||
peer,
|
||||
docId,
|
||||
});
|
||||
return clock
|
||||
? {
|
||||
docId: clock.docId,
|
||||
timestamp: new Date(clock.timestamp),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
setPeerPushedClock: async function (
|
||||
id: string,
|
||||
peer: string,
|
||||
docId: string,
|
||||
clock: Date
|
||||
): Promise<void> {
|
||||
await NbStore.setPeerPushedClock({
|
||||
id,
|
||||
peer,
|
||||
docId,
|
||||
timestamp: clock.getTime(),
|
||||
});
|
||||
},
|
||||
clearClocks: async function (id: string): Promise<void> {
|
||||
await NbStore.clearClocks({
|
||||
id,
|
||||
});
|
||||
},
|
||||
getBlobUploadedAt: async function (
|
||||
id: string,
|
||||
peer: string,
|
||||
blobId: string
|
||||
): Promise<Date | null> {
|
||||
const result = await NbStore.getBlobUploadedAt({
|
||||
id,
|
||||
peer,
|
||||
blobId,
|
||||
});
|
||||
return result.uploadedAt ? new Date(result.uploadedAt) : null;
|
||||
},
|
||||
setBlobUploadedAt: async function (
|
||||
id: string,
|
||||
peer: string,
|
||||
blobId: string,
|
||||
uploadedAt: Date | null
|
||||
): Promise<void> {
|
||||
await NbStore.setBlobUploadedAt({
|
||||
id,
|
||||
peer,
|
||||
blobId,
|
||||
uploadedAt: uploadedAt ? uploadedAt.getTime() : null,
|
||||
});
|
||||
},
|
||||
};
|
||||
65
packages/frontend/apps/android/src/proxy.ts
Normal file
65
packages/frontend/apps/android/src/proxy.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { openDB } from 'idb';
|
||||
|
||||
/**
|
||||
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
|
||||
* should be included in the entry file of the app or webworker.
|
||||
*/
|
||||
const rawFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
|
||||
const origin = new URL(request.url, globalThis.location.origin).origin;
|
||||
|
||||
const token = await readEndpointToken(origin);
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return rawFetch(request);
|
||||
};
|
||||
|
||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const origin = new URL(this.responseURL, globalThis.location.origin).origin;
|
||||
|
||||
readEndpointToken(origin).then(
|
||||
token => {
|
||||
if (token) {
|
||||
this.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return super.send(body);
|
||||
},
|
||||
() => {
|
||||
throw new Error('Failed to read token');
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export async function readEndpointToken(
|
||||
endpoint: string
|
||||
): Promise<string | null> {
|
||||
const idb = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const token = await idb.get('tokens', endpoint);
|
||||
return token ? token.token : null;
|
||||
}
|
||||
|
||||
export async function writeEndpointToken(endpoint: string, token: string) {
|
||||
const db = await openDB('affine-token', 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains('tokens')) {
|
||||
db.createObjectStore('tokens', { keyPath: 'endpoint' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await db.put('tokens', { endpoint, token });
|
||||
}
|
||||
2
packages/frontend/apps/android/src/setup-worker.ts
Normal file
2
packages/frontend/apps/android/src/setup-worker.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '@affine/core/bootstrap/browser';
|
||||
import './proxy';
|
||||
@@ -2,3 +2,4 @@ import '@affine/core/bootstrap/browser';
|
||||
import '@affine/core/bootstrap/blocksuite';
|
||||
import '@affine/component/theme';
|
||||
import '@affine/core/mobile/styles/mobile.css';
|
||||
import './proxy';
|
||||
|
||||
@@ -92,17 +92,20 @@ function KeyboardToolbarExtension(framework: FrameworkProvider): ExtensionType {
|
||||
}
|
||||
|
||||
if ('show' in affineVirtualKeyboardProvider) {
|
||||
return class BSVirtualKeyboardWithActionService
|
||||
const providerWithAction = affineVirtualKeyboardProvider;
|
||||
class BSVirtualKeyboardServiceWithShowAndHide
|
||||
extends BSVirtualKeyboardService
|
||||
implements VirtualKeyboardProviderWithAction
|
||||
{
|
||||
show() {
|
||||
affineVirtualKeyboardProvider.show();
|
||||
providerWithAction.show();
|
||||
}
|
||||
hide() {
|
||||
affineVirtualKeyboardProvider.hide();
|
||||
providerWithAction.hide();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return BSVirtualKeyboardServiceWithShowAndHide;
|
||||
}
|
||||
|
||||
return BSVirtualKeyboardService;
|
||||
|
||||
@@ -97,7 +97,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
readonly flavour = this.server.id;
|
||||
|
||||
DocStorageType =
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
|
||||
? SqliteDocStorage
|
||||
: IndexedDBDocStorage;
|
||||
DocStorageV1Type = BUILD_CONFIG.isElectron
|
||||
@@ -106,7 +106,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
? IndexedDBV1DocStorage
|
||||
: undefined;
|
||||
BlobStorageType =
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
|
||||
? SqliteBlobStorage
|
||||
: IndexedDBBlobStorage;
|
||||
BlobStorageV1Type = BUILD_CONFIG.isElectron
|
||||
@@ -115,11 +115,11 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
? IndexedDBV1BlobStorage
|
||||
: undefined;
|
||||
DocSyncStorageType =
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
|
||||
? SqliteDocSyncStorage
|
||||
: IndexedDBDocSyncStorage;
|
||||
BlobSyncStorageType =
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
|
||||
? SqliteBlobSyncStorage
|
||||
: IndexedDBBlobSyncStorage;
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
);
|
||||
|
||||
DocStorageType =
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
|
||||
? SqliteDocStorage
|
||||
: IndexedDBDocStorage;
|
||||
DocStorageV1Type = BUILD_CONFIG.isElectron
|
||||
@@ -91,7 +91,7 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
? IndexedDBV1DocStorage
|
||||
: undefined;
|
||||
BlobStorageType =
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
|
||||
? SqliteBlobStorage
|
||||
: IndexedDBBlobStorage;
|
||||
BlobStorageV1Type = BUILD_CONFIG.isElectron
|
||||
@@ -100,11 +100,11 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||
? IndexedDBV1BlobStorage
|
||||
: undefined;
|
||||
DocSyncStorageType =
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
|
||||
? SqliteDocSyncStorage
|
||||
: IndexedDBDocSyncStorage;
|
||||
BlobSyncStorageType =
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid
|
||||
? SqliteBlobSyncStorage
|
||||
: IndexedDBBlobSyncStorage;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user