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:
Aki Chang
2025-04-14 14:05:47 +08:00
committed by GitHub
parent 08dbaae19b
commit 00bd05897e
58 changed files with 3049 additions and 2168 deletions

View File

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

View File

@@ -4,6 +4,7 @@ App/output
App/App/public
DerivedData
xcuserdata
*.log
# Cordova plugins for Capacitor
capacitor-cordova-ios-plugins

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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!",
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package app.affine.pro.theme
import androidx.compose.material3.Typography
val AffineTypography = Typography()

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
mutation CreateCopilotSession($options: CreateChatSessionInput!) {
createCopilotSession(options: $options)
}

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 };

View File

@@ -0,0 +1,6 @@
export interface HashCashPlugin {
hash(options: {
challenge: string;
bits?: number;
}): Promise<{ value: string }>;
}

View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { HashCashPlugin } from './definitions';
const HashCash = registerPlugin<HashCashPlugin>('HashCash');
export * from './definitions';
export { HashCash };

View File

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

View 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,
});
},
};

View 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 });
}

View File

@@ -0,0 +1,2 @@
import '@affine/core/bootstrap/browser';
import './proxy';

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -260,7 +260,9 @@ __metadata:
"@toeverything/infra": "workspace:*"
"@types/react": "npm:^19.0.1"
"@types/react-dom": "npm:^19.0.2"
async-call-rpc: "npm:^6.4.2"
cross-env: "npm:^7.0.3"
idb: "npm:^8.0.0"
next-themes: "npm:^0.4.4"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"