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