From 00bd05897e6b58a16f7530ef64bfaedf10a28a84 Mon Sep 17 00:00:00 2001 From: Aki Chang Date: Mon, 14 Apr 2025 14:05:47 +0800 Subject: [PATCH] feat(android): ai chat scaffold (#11124) Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: eyhn --- .github/workflows/release-mobile.yml | 2 +- packages/frontend/apps/android/.gitignore | 1 + .../apps/android/App/app/build.gradle | 67 +- .../App/app/src/main/AndroidManifest.xml | 21 +- .../src/main/java/app/affine/pro/AffineApp.kt | 16 + .../java/app/affine/pro/CapacitorConfig.kt | 25 + .../main/java/app/affine/pro/MainActivity.kt | 25 +- .../main/java/app/affine/pro/ai/AIActivity.kt | 132 ++ .../main/java/app/affine/pro/ai/Constants.kt | 21 + .../app/affine/pro/ai/chat/ChatUiState.kt | 37 + .../app/affine/pro/ai/chat/ChatViewModel.kt | 93 + .../app/affine/pro/ai/chat/ui/ChatAppBar.kt | 70 + .../java/app/affine/pro/ai/chat/ui/Message.kt | 97 + .../app/affine/pro/ai/chat/ui/UserInput.kt | 286 +++ .../app/affine/pro/components/AffineAppBar.kt | 107 + .../app/affine/pro/plugin/AIButtonPlugin.kt | 12 +- .../java/app/affine/pro/plugin/AuthPlugin.kt | 164 ++ .../app/affine/pro/plugin/HashCashPlugin.kt | 24 + .../app/affine/pro/plugin/NbStorePlugin.kt | 578 +++++ .../java/app/affine/pro/plugin/PluginExt.kt | 28 + .../java/app/affine/pro/repo/GraphQLRepo.kt | 75 + .../main/java/app/affine/pro/repo/SSERepo.kt | 61 + .../main/java/app/affine/pro/repo/WebRepo.kt | 35 + .../app/affine/pro/service/AffineClient.kt | 30 - .../app/affine/pro/service/GraphQLClient.kt | 43 + .../java/app/affine/pro/service/OkHttp.kt | 53 + .../main/java/app/affine/pro/theme/Color.kt | 16 + .../main/java/app/affine/pro/theme/Theme.kt | 31 + .../java/app/affine/pro/theme/Typography.kt | 5 + .../App/app/src/main/res/values/styles.xml | 2 +- .../frontend/apps/android/App/build.gradle | 12 +- .../buildscripts/toml-updater-config.gradle | 30 + .../build.gradle | 2 +- .../src/main/AndroidManifest.xml | 2 +- .../apps/android/App/gradle.properties | 2 + .../android/App/gradle/libs.versions.toml | 135 +- .../apps/android/App/service/build.gradle | 10 +- .../affine/CreateCopilotSession.graphql | 3 - .../src/main/graphql/affine/schema.graphqls | 2029 ----------------- .../apps/android/App/variables.gradle | 7 +- .../frontend/apps/android/capacitor.config.ts | 29 +- packages/frontend/apps/android/package.json | 4 + packages/frontend/apps/android/src/app.tsx | 90 +- packages/frontend/apps/android/src/index.tsx | 4 + .../apps/android/src/nbstore.worker.ts | 69 +- .../android/src/plugins/auth/definitions.ts | 22 + .../apps/android/src/plugins/auth/index.ts | 8 + .../src/plugins/hashcash/definitions.ts | 6 + .../android/src/plugins/hashcash/index.ts | 8 + .../src/plugins/nbstore/definitions.ts | 152 ++ .../apps/android/src/plugins/nbstore/index.ts | 339 +++ packages/frontend/apps/android/src/proxy.ts | 65 + .../frontend/apps/android/src/setup-worker.ts | 2 + packages/frontend/apps/android/src/setup.ts | 1 + .../extensions/entry/enable-mobile.ts | 11 +- .../modules/workspace-engine/impls/cloud.ts | 8 +- .../modules/workspace-engine/impls/local.ts | 8 +- yarn.lock | 2 + 58 files changed, 3049 insertions(+), 2168 deletions(-) create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineApp.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/CapacitorConfig.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/AIActivity.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/Constants.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ChatUiState.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ChatViewModel.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/ChatAppBar.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/Message.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/UserInput.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/components/AffineAppBar.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/HashCashPlugin.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/NbStorePlugin.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/PluginExt.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/GraphQLRepo.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/SSERepo.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/WebRepo.kt delete mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/AffineClient.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/GraphQLClient.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/OkHttp.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Color.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Theme.kt create mode 100644 packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Typography.kt create mode 100644 packages/frontend/apps/android/App/buildscripts/toml-updater-config.gradle delete mode 100644 packages/frontend/apps/android/App/service/src/main/graphql/affine/CreateCopilotSession.graphql delete mode 100644 packages/frontend/apps/android/App/service/src/main/graphql/affine/schema.graphqls create mode 100644 packages/frontend/apps/android/src/plugins/auth/definitions.ts create mode 100644 packages/frontend/apps/android/src/plugins/auth/index.ts create mode 100644 packages/frontend/apps/android/src/plugins/hashcash/definitions.ts create mode 100644 packages/frontend/apps/android/src/plugins/hashcash/index.ts create mode 100644 packages/frontend/apps/android/src/plugins/nbstore/definitions.ts create mode 100644 packages/frontend/apps/android/src/plugins/nbstore/index.ts create mode 100644 packages/frontend/apps/android/src/proxy.ts create mode 100644 packages/frontend/apps/android/src/setup-worker.ts diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml index 0d770749e4..cf1bb636eb 100644 --- a/.github/workflows/release-mobile.yml +++ b/.github/workflows/release-mobile.yml @@ -263,5 +263,5 @@ jobs: packageName: app.affine.pro releaseFiles: packages/frontend/apps/android/App/app/build/outputs/bundle/${{ env.BUILD_TYPE }}Release/app-${{ env.BUILD_TYPE }}-release-signed.aab track: internal - status: draft + status: complete existingEditId: ${{ steps.bump.outputs.EDIT_ID }} diff --git a/packages/frontend/apps/android/.gitignore b/packages/frontend/apps/android/.gitignore index 1ec77b9499..53a32afb7a 100644 --- a/packages/frontend/apps/android/.gitignore +++ b/packages/frontend/apps/android/.gitignore @@ -4,6 +4,7 @@ App/output App/App/public DerivedData xcuserdata +*.log # Cordova plugins for Capacitor capacitor-cordova-ios-plugins diff --git a/packages/frontend/apps/android/App/app/build.gradle b/packages/frontend/apps/android/App/app/build.gradle index baa55614af..c53a0c838c 100644 --- a/packages/frontend/apps/android/App/app/build.gradle +++ b/packages/frontend/apps/android/App/app/build.gradle @@ -3,7 +3,12 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { alias libs.plugins.android.application + alias libs.plugins.compose + alias libs.plugins.hilt alias libs.plugins.kotlin.android + alias libs.plugins.kotlin.parcelize + alias libs.plugins.kotlin.serialization + alias libs.plugins.ksp alias libs.plugins.rust.android } @@ -26,17 +31,23 @@ android { ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } ndk { - abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64' + abiFilters 'arm64-v8a' } } buildFeatures { + compose true buildConfig true + viewBinding true } buildTypes { release { - minifyEnabled false + minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } + debug { + minifyEnabled false + debuggable true + } } flavorDimensions = ['chanel'] productFlavors { @@ -66,17 +77,54 @@ dependencies { implementation project(':capacitor-android') implementation project(':capacitor-cordova-android-plugins') implementation project(':service') - implementation libs.kotlinx.coroutines.core - implementation libs.kotlinx.coroutines.android + implementation libs.androidx.appcompat implementation libs.androidx.browser + implementation libs.timber + + implementation libs.hilt.android.core + ksp libs.hilt.compiler + + def composeBom = platform(libs.androidx.compose.bom) + implementation composeBom + implementation libs.androidx.activity.compose + implementation libs.androidx.compose.foundation.layout + implementation libs.androidx.compose.material3 + implementation libs.androidx.compose.material.icons.extended + implementation libs.androidx.compose.runtime.livedata + implementation libs.androidx.compose.ui.tooling.preview + implementation libs.androidx.compose.ui.util + implementation libs.androidx.compose.ui.viewbinding + implementation libs.androidx.compose.ui.googlefonts + implementation libs.androidx.lifecycle.viewModelCompose + implementation libs.androidx.lifecycle.runtime.compose + implementation libs.androidx.navigation.compose + debugImplementation composeBom + debugImplementation libs.androidx.compose.ui.test.manifest + debugImplementation libs.androidx.compose.ui.tooling + implementation libs.androidx.coordinatorlayout - implementation libs.androidx.core.splashscreen implementation libs.androidx.core.ktx - implementation libs.androidx.material3 + implementation libs.androidx.core.splashscreen + implementation libs.androidx.navigation.fragment + implementation libs.androidx.navigation.ui.ktx + implementation libs.apollo.runtime - implementation libs.google.material - implementation libs.jna + implementation (libs.jna) { + artifact { + type = 'aar' + } + } + implementation libs.kotlinx.coroutines.android + implementation libs.kotlinx.serialization.json + + def okhttpBom = platform(libs.okhttp.bom) + implementation okhttpBom + implementation libs.okhttp + implementation libs.okhttp.coroutines + implementation libs.okhttp.logging + implementation libs.okhttp.sse + testImplementation libs.junit androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.espresso.core @@ -105,6 +153,7 @@ cargo { kotlin { compilerOptions { apiVersion = KotlinVersion.KOTLIN_2_1 + languageVersion = KotlinVersion.KOTLIN_2_1 jvmTarget = JvmTarget.JVM_21 } } @@ -125,7 +174,7 @@ android.applicationVariants.configureEach { variant -> def t = tasks.register("generate${variant.name.capitalize()}UniFFIBindings", Exec) { workingDir "${project.projectDir}" // Runs the bindings generation, note that you must have uniffi-bindgen installed and in your PATH environment variable - commandLine "cargo", 'run', '--bin', 'uniffi-bindgen', 'generate', '--library', "${buildDir}/rustJniLibs/android/arm64-v8a/libaffine_mobile_native.so", '--language', 'kotlin', '--out-dir', "${project.projectDir}/src/main/java" + commandLine 'cargo', 'run', '--bin', 'uniffi-bindgen', 'generate', '--library', "${buildDir}/rustJniLibs/android/arm64-v8a/libaffine_mobile_native.so", '--language', 'kotlin', '--out-dir', "${project.projectDir}/src/main/java" dependsOn("cargoBuild") } variant.javaCompileProvider.get().dependsOn(t) diff --git a/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml b/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml index d8249c6476..2300915964 100644 --- a/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml +++ b/packages/frontend/apps/android/App/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + android:theme="@style/AppTheme.NoActionBarLaunch"> @@ -30,12 +31,22 @@ + - + + + + + android:resource="@xml/file_paths" /> diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineApp.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineApp.kt new file mode 100644 index 0000000000..359c5383a6 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/AffineApp.kt @@ -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) + } + +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/CapacitorConfig.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/CapacitorConfig.kt new file mode 100644 index 0000000000..b3dc569325 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/CapacitorConfig.kt @@ -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 +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt index 12ecb05163..4f9590adfb 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/MainActivity.kt @@ -3,31 +3,40 @@ package app.affine.pro import android.content.res.ColorStateList import android.view.Gravity import android.view.View -import android.widget.Toast import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat import androidx.core.view.updateMargins import androidx.lifecycle.lifecycleScope +import app.affine.pro.ai.AIActivity import app.affine.pro.plugin.AIButtonPlugin import app.affine.pro.plugin.AffineThemePlugin +import app.affine.pro.plugin.AuthPlugin +import app.affine.pro.plugin.HashCashPlugin +import app.affine.pro.plugin.NbStorePlugin +import app.affine.pro.repo.WebRepo import app.affine.pro.utils.dp import com.getcapacitor.BridgeActivity -import com.getcapacitor.plugin.CapacitorCookies -import com.getcapacitor.plugin.CapacitorHttp import com.google.android.material.floatingactionbutton.FloatingActionButton +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import javax.inject.Inject +@AndroidEntryPoint class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugin.Callback, View.OnClickListener { + @Inject + lateinit var webRepo: WebRepo + init { registerPlugins( listOf( AffineThemePlugin::class.java, AIButtonPlugin::class.java, - CapacitorHttp::class.java, - CapacitorCookies::class.java, + AuthPlugin::class.java, + HashCashPlugin::class.java, + NbStorePlugin::class.java, ) ) } @@ -75,6 +84,10 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AffineThemePlugi } override fun onClick(v: View) { - Toast.makeText(this, "TODO: Start AI chat~", Toast.LENGTH_SHORT).show() + lifecycleScope.launch { + webRepo.init(bridge) + AIActivity.open(this@MainActivity) + } } + } diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/AIActivity.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/AIActivity.kt new file mode 100644 index 0000000000..dfbd320fdb --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/AIActivity.kt @@ -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() + + 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)) + } + } + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/Constants.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/Constants.kt new file mode 100644 index 0000000000..eaf8268772 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/Constants.kt @@ -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"), +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ChatUiState.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ChatUiState.kt new file mode 100644 index 0000000000..1e4e3c53a5 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ChatUiState.kt @@ -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 +) : 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 + ) + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ChatViewModel.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ChatViewModel.kt new file mode 100644 index 0000000000..d43eb389c1 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ChatViewModel.kt @@ -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 = + MutableStateFlow(MessageUiState(emptyList())) + + val uiState: StateFlow = _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() + } + } + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/ChatAppBar.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/ChatAppBar.kt new file mode 100644 index 0000000000..50e22d6e5d --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/ChatAppBar.kt @@ -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, + ) + } + ) + } + ) +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/Message.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/Message.kt new file mode 100644 index 0000000000..57631c4ce8 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/Message.kt @@ -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(), + ) + ) + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/UserInput.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/UserInput.kt new file mode 100644 index 0000000000..e2993ccdc2 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/ai/chat/ui/UserInput.kt @@ -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("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!", + ) + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/components/AffineAppBar.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/components/AffineAppBar.kt new file mode 100644 index 0000000000..cd4c58aeaa --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/components/AffineAppBar.kt @@ -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") + }, + ) + }, + ) + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AIButtonPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AIButtonPlugin.kt index f3bdd4ebe4..a692d643b9 100644 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AIButtonPlugin.kt +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AIButtonPlugin.kt @@ -15,13 +15,17 @@ class AIButtonPlugin : Plugin() { @PluginMethod fun present(call: PluginCall) { - (activity as? Callback)?.present() - call.resolve() + launch { + (activity as? Callback)?.present() + call.resolve() + } } @PluginMethod fun dismiss(call: PluginCall) { - (activity as? Callback)?.dismiss() - call.resolve() + launch { + (activity as? Callback)?.dismiss() + call.resolve() + } } } \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt new file mode 100644 index 0000000000..1d762d32f8 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/AuthPlugin.kt @@ -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) + } + } + } +} diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/HashCashPlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/HashCashPlugin.kt new file mode 100644 index 0000000000..32994601ba --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/HashCashPlugin.kt @@ -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())) + }) + } + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/NbStorePlugin.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/NbStorePlugin.kt new file mode 100644 index 0000000000..7fcbc45a0b --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/NbStorePlugin.kt @@ -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("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) + } + } + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/PluginExt.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/PluginExt.kt new file mode 100644 index 0000000000..be90a8c7e7 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/plugin/PluginExt.kt @@ -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 PluginCall.getListEnsure(key: String): List { + return getArray(key)?.toList() ?: throw IllegalArgumentException("Missing $key parameter") +} + +fun PluginCall.getLongEnsure(key: String): Long { + return getLong(key) ?: throw IllegalArgumentException("Missing $key parameter") +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/GraphQLRepo.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/GraphQLRepo.kt new file mode 100644 index 0000000000..151d1d27b9 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/GraphQLRepo.kt @@ -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." + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/SSERepo.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/SSERepo.kt new file mode 100644 index 0000000000..d41df7c052 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/SSERepo.kt @@ -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> { + val request = Request.Builder() + .get() + .url(this) + .build() + return callbackFlow> { + 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) + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/WebRepo.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/WebRepo.kt new file mode 100644 index 0000000000..97f4da1ffa --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/repo/WebRepo.kt @@ -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 +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/AffineClient.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/AffineClient.kt deleted file mode 100644 index 81b108be79..0000000000 --- a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/AffineClient.kt +++ /dev/null @@ -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 query(query: Query): ApolloResponse { - return _client.query(query).execute() - } - - suspend fun mutation(mutation: Mutation): ApolloResponse { - return _client.mutation(mutation).execute() - } - - suspend fun subscription(subscription: Subscription): ApolloResponse { - return _client.subscription(subscription).execute() - } -} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/GraphQLClient.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/GraphQLClient.kt new file mode 100644 index 0000000000..3bbffa0b62 --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/GraphQLClient.kt @@ -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 query(query: Query) = withContext(Dispatchers.IO) { + runCatching { + withContext(Dispatchers.IO) { + _client.query(query).execute().dataOrThrow() + } + } + } + + suspend fun mutation(mutation: Mutation) = withContext(Dispatchers.IO) { + runCatching { + _client.mutation(mutation).execute().dataOrThrow() + } + } + + suspend fun subscription(subscription: Subscription) = + withContext(Dispatchers.IO) { + runCatching { + _client.subscription(subscription).execute().dataOrThrow() + } + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/OkHttp.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/OkHttp.kt new file mode 100644 index 0000000000..becef5727b --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/service/OkHttp.kt @@ -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 { + val cookies = CookieStore.getCookies(url.host) + Timber.d("load cookies: [ url = $url, cookies = $cookies]") + return cookies + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + 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>() + + fun saveCookies(host: String, cookies: List) { + _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 + +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Color.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Color.kt new file mode 100644 index 0000000000..5562e946eb --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Color.kt @@ -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) +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Theme.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Theme.kt new file mode 100644 index 0000000000..ef249affed --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Typography.kt b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Typography.kt new file mode 100644 index 0000000000..61a0f175fc --- /dev/null +++ b/packages/frontend/apps/android/App/app/src/main/java/app/affine/pro/theme/Typography.kt @@ -0,0 +1,5 @@ +package app.affine.pro.theme + +import androidx.compose.material3.Typography + +val AffineTypography = Typography() \ No newline at end of file diff --git a/packages/frontend/apps/android/App/app/src/main/res/values/styles.xml b/packages/frontend/apps/android/App/app/src/main/res/values/styles.xml index 96e9352098..5ee77ef07b 100644 --- a/packages/frontend/apps/android/App/app/src/main/res/values/styles.xml +++ b/packages/frontend/apps/android/App/app/src/main/res/values/styles.xml @@ -15,8 +15,8 @@ @null - + \ No newline at end of file diff --git a/packages/frontend/apps/android/App/build.gradle b/packages/frontend/apps/android/App/build.gradle index 8903669836..b0f3d5766a 100644 --- a/packages/frontend/apps/android/App/build.gradle +++ b/packages/frontend/apps/android/App/build.gradle @@ -11,21 +11,29 @@ buildscript { gradlePluginPortal() } dependencies { - classpath libs.android.gradlePlugin + classpath libs.android.gradle.plugin classpath libs.google.services } } plugins { + alias libs.plugins.gradle.versions + alias libs.plugins.version.catalog.update alias libs.plugins.android.application apply false alias libs.plugins.android.library apply false alias libs.plugins.kotlin.android apply false + alias libs.plugins.kotlin.parcelize apply false + alias libs.plugins.kotlin.serialization apply false + alias libs.plugins.compose apply false + alias libs.plugins.ksp apply false + alias libs.plugins.hilt apply false alias libs.plugins.rust.android apply false alias libs.plugins.apollo.android apply false - alias(libs.plugins.jetbrains.kotlin.jvm) apply false + alias libs.plugins.jetbrains.kotlin.jvm apply false } apply from: "variables.gradle" +apply from: "${project.rootDir}/buildscripts/toml-updater-config.gradle" allprojects { repositories { diff --git a/packages/frontend/apps/android/App/buildscripts/toml-updater-config.gradle b/packages/frontend/apps/android/App/buildscripts/toml-updater-config.gradle new file mode 100644 index 0000000000..946f17e79d --- /dev/null +++ b/packages/frontend/apps/android/App/buildscripts/toml-updater-config.gradle @@ -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') + } + } + } + } +} \ No newline at end of file diff --git a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle index 9c83ea7179..1192ef868a 100644 --- a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle +++ b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/build.gradle @@ -9,7 +9,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.9.1' + classpath 'com.android.tools.build:gradle:8.7.2' } } diff --git a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml index cb9c8aa354..91d30c6275 100644 --- a/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml +++ b/packages/frontend/apps/android/App/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/packages/frontend/apps/android/App/gradle.properties b/packages/frontend/apps/android/App/gradle.properties index 2e87c52f83..5d2cc73c0d 100644 --- a/packages/frontend/apps/android/App/gradle.properties +++ b/packages/frontend/apps/android/App/gradle.properties @@ -20,3 +20,5 @@ org.gradle.jvmargs=-Xmx1536m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true +ksp.incremental.apt=true +ksp.useKSP2=true diff --git a/packages/frontend/apps/android/App/gradle/libs.versions.toml b/packages/frontend/apps/android/App/gradle/libs.versions.toml index be8e7264cd..46b8f745fe 100644 --- a/packages/frontend/apps/android/App/gradle/libs.versions.toml +++ b/packages/frontend/apps/android/App/gradle/libs.versions.toml @@ -1,46 +1,101 @@ [versions] -androidGradlePlugin = "8.9.1" -androidxEspressoCore = "3.6.1" -androidxJunit = "1.2.1" -apollo = "4.1.1" -appcompat = "1.7.0" -browser = "1.8.0" -coordinatorLayout = "1.3.0" -coreKtx = "1.16.0" -coreSplashScreen = "1.0.1" -googleServices = "4.4.2" -jna = "5.17.0" -junitVersion = "4.13.2" -kotlin = "2.1.20" -kotlinxCoroutines = "1.10.2" -material = "1.12.0" -material3 = "1.3.2" -rustAndroid = "0.9.6" +# @keep +android-gradle-plugin = "8.9.1" +androidx-activity-compose = "1.10.1" +androidx-appcompat = "1.7.0" +androidx-browser = "1.8.0" +androidx-compose-bom = "2025.03.01" +androidx-coordinatorlayout = "1.3.0" +androidx-core-ktx = "1.15.0" +androidx-core-splashscreen = "1.0.1" +androidx-espresso-core = "3.6.1" +androidx-junit = "1.2.1" +androidx-lifecycle-compose = "2.8.7" +androidx-material3 = "1.3.1" +androidx-navigation = "2.8.9" +apollo = "4.1.1" +apollo-kotlin-adapters = "0.0.4" +# @keep +compileSdk = "35" +google-services = "4.4.2" +gradle-versions = "0.52.0" +hilt = "2.56.1" +hilt-ext = "1.2.0" +jna = "5.17.0" +junit = "4.13.2" +kotlin = "2.1.20" +kotlinx-coroutines = "1.10.1" +kotlinx-datetime = "0.6.2" +kotlinx-serialization-json = "1.8.0" +ksp = "2.1.20-1.0.32" +# @keep +minSdk = "22" +mozilla-rust-android = "0.9.6" +okhttp = "5.0.0-alpha.14" +# @keep +targetSdk = "35" +timber = "5.0.1" +version-catalog-update = "0.8.5" [libraries] -androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } -androidx-browser = { module = "androidx.browser:browser", version.ref = "browser" } -androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "coordinatorLayout" } -androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } -androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashScreen" } -androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxEspressoCore" } -androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } -androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } -kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } - -android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" } -apollo-api = { module = "com.apollographql.apollo:apollo-api", version.ref = "apollo" } -apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } -google-material = { module = "com.google.android.material:material", version.ref = "material" } -google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" } -jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } -junit = { module = "junit:junit", version.ref = "junitVersion" } +android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle-plugin" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } +androidx-compose-ui-googlefonts = { module = "androidx.compose.ui:ui-text-google-fonts" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" } +androidx-compose-ui-viewbinding = { module = "androidx.compose.ui:ui-viewbinding" } +androidx-coordinatorlayout = { module = "androidx.coordinatorlayout:coordinatorlayout", version.ref = "androidx-coordinatorlayout" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso-core" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +apollo-adapters-core = { module = "com.apollographql.adapters:apollo-adapters-core", version.ref = "apollo-kotlin-adapters"} +apollo-adapters-kotlinx-datetime = { module = "com.apollographql.adapters:apollo-adapters-kotlinx-datetime", version.ref = "apollo-kotlin-adapters"} +apollo-api = { module = "com.apollographql.apollo:apollo-api", version.ref = "apollo" } +apollo-runtime = { module = "com.apollographql.apollo:apollo-runtime", version.ref = "apollo" } +google-services = { module = "com.google.gms:google-services", version.ref = "google-services" } +hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-ext-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-ext" } +hilt-ext-work = { module = "androidx.hilt:hilt-work", version.ref = "hilt-ext" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +okhttp = { module = "com.squareup.okhttp3:okhttp" } +okhttp-coroutines = { module = "com.squareup.okhttp3:okhttp-coroutines" } +okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor" } +okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } [plugins] -android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } -android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } -apollo-android = { id = "com.apollographql.apollo", version.ref = "apollo" } +android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } +android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } +apollo-android = { id = "com.apollographql.apollo", version.ref = "apollo" } +compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -rust-android = { id = "org.mozilla.rust-android-gradle.rust-android", version.ref = "rustAndroid" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +rust-android = { id = "org.mozilla.rust-android-gradle.rust-android", version.ref = "mozilla-rust-android" } +version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "version-catalog-update" } diff --git a/packages/frontend/apps/android/App/service/build.gradle b/packages/frontend/apps/android/App/service/build.gradle index 337d0ebaee..551b5d5b2b 100644 --- a/packages/frontend/apps/android/App/service/build.gradle +++ b/packages/frontend/apps/android/App/service/build.gradle @@ -5,14 +5,16 @@ plugins { dependencies { implementation libs.apollo.api + implementation libs.apollo.adapters.core + implementation libs.apollo.adapters.kotlinx.datetime + api libs.kotlinx.datetime } apollo { service("affine") { + srcDir("../../../../../common/graphql/src/graphql") + schemaFiles.from("../../../../../backend/server/src/schema.gql") packageName.set("com.affine.pro.graphql") - introspection { - endpointUrl.set("https://app.affine.pro/graphql") - schemaFile.set(file("src/main/graphql/affine/schema.graphqls")) - } + mapScalar("DateTime", "kotlinx.datetime.Instant", "com.apollographql.adapter.datetime.KotlinxInstantAdapter") } } \ No newline at end of file diff --git a/packages/frontend/apps/android/App/service/src/main/graphql/affine/CreateCopilotSession.graphql b/packages/frontend/apps/android/App/service/src/main/graphql/affine/CreateCopilotSession.graphql deleted file mode 100644 index 0a9b74750c..0000000000 --- a/packages/frontend/apps/android/App/service/src/main/graphql/affine/CreateCopilotSession.graphql +++ /dev/null @@ -1,3 +0,0 @@ -mutation CreateCopilotSession($options: CreateChatSessionInput!) { - createCopilotSession(options: $options) -} diff --git a/packages/frontend/apps/android/App/service/src/main/graphql/affine/schema.graphqls b/packages/frontend/apps/android/App/service/src/main/graphql/affine/schema.graphqls deleted file mode 100644 index 2e16b651c5..0000000000 --- a/packages/frontend/apps/android/App/service/src/main/graphql/affine/schema.graphqls +++ /dev/null @@ -1,2029 +0,0 @@ -type AlreadyInSpaceDataType { - spaceId: String! -} - -type BlobNotFoundDataType { - blobId: String! - - spaceId: String! -} - -enum ChatHistoryOrder { - asc - - desc -} - -type ChatMessage { - attachments: [String!] - - content: String! - - createdAt: DateTime! - - id: ID - - params: JSON - - role: String! -} - -type Copilot { - """ - Get the session list of actions in the workspace - """ - actions: [String!]! - - """ - Get the session list of chats in the workspace - """ - chats: [String!]! - - histories( - docId: String - options: QueryChatHistoriesInput - ): [CopilotHistories!]! - - """ - Get the quota of the user in the workspace - """ - quota: CopilotQuota! - - workspaceId: ID -} - -type CopilotHistories { - """ - An mark identifying which view to use to display the session - """ - action: String - - createdAt: DateTime! - - messages: [ChatMessage!]! - - sessionId: String! - - """ - The number of tokens used in the session - """ - tokens: Int! -} - -type CopilotMessageNotFoundDataType { - messageId: String! -} - -enum CopilotModels { - DallE3 - - Gpt4Omni - - Gpt4Omni0806 - - Gpt4OmniMini - - Gpt4OmniMini0718 - - TextEmbedding3Large - - TextEmbedding3Small - - TextEmbeddingAda002 - - TextModerationLatest - - TextModerationStable -} - -input CopilotPromptConfigInput { - frequencyPenalty: Float - - jsonMode: Boolean - - presencePenalty: Float - - temperature: Float - - topP: Float -} - -type CopilotPromptConfigType { - frequencyPenalty: Float - - jsonMode: Boolean - - presencePenalty: Float - - temperature: Float - - topP: Float -} - -input CopilotPromptMessageInput { - content: String! - - params: JSON - - role: CopilotPromptMessageRole! -} - -enum CopilotPromptMessageRole { - assistant - - system - - user -} - -type CopilotPromptMessageType { - content: String! - - params: JSON - - role: CopilotPromptMessageRole! -} - -type CopilotPromptNotFoundDataType { - name: String! -} - -type CopilotPromptType { - action: String - - config: CopilotPromptConfigType - - messages: [CopilotPromptMessageType!]! - - model: String! - - name: String! -} - -type CopilotProviderSideErrorDataType { - kind: String! - - message: String! - - provider: String! -} - -type CopilotQuota { - limit: SafeInt - - used: SafeInt! -} - -input CreateChatMessageInput { - attachments: [String!] - - blobs: [Upload!] - - content: String - - params: JSON - - sessionId: String! -} - -input CreateChatSessionInput { - docId: String! - - """ - The prompt name to use for the session - """ - promptName: String! - - workspaceId: String! -} - -input CreateCheckoutSessionInput { - args: JSONObject - - coupon: String - - idempotencyKey: String - - plan: SubscriptionPlan = Pro - - recurring: SubscriptionRecurring = Yearly - - successCallbackLink: String! - - variant: SubscriptionVariant -} - -input CreateCopilotPromptInput { - action: String - - config: CopilotPromptConfigInput - - messages: [CopilotPromptMessageInput!]! - - model: CopilotModels! - - name: String! -} - -input CreateUserInput { - email: String! - - name: String -} - -type CredentialsRequirementType { - password: PasswordLimitsType! -} - -""" -A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -""" -scalar DateTime - -type DeleteAccount { - success: Boolean! -} - -input DeleteSessionInput { - docId: String! - - sessionIds: [String!]! - - workspaceId: String! -} - -type DocAccessDeniedDataType { - docId: String! - - spaceId: String! -} - -type DocHistoryNotFoundDataType { - docId: String! - - spaceId: String! - - timestamp: Int! -} - -type DocHistoryType { - editor: EditorType - - id: String! - - timestamp: DateTime! - - workspaceId: String! -} - -type DocNotFoundDataType { - docId: String! - - spaceId: String! -} - -type EditorType { - avatarUrl: String - - name: String! -} - -union ErrorDataUnion = - | AlreadyInSpaceDataType - | BlobNotFoundDataType - | CopilotMessageNotFoundDataType - | CopilotPromptNotFoundDataType - | CopilotProviderSideErrorDataType - | DocAccessDeniedDataType - | DocHistoryNotFoundDataType - | DocNotFoundDataType - | InvalidEmailDataType - | InvalidHistoryTimestampDataType - | InvalidPasswordLengthDataType - | InvalidRuntimeConfigTypeDataType - | MemberNotFoundInSpaceDataType - | MissingOauthQueryParameterDataType - | NotInSpaceDataType - | RuntimeConfigNotFoundDataType - | SameSubscriptionRecurringDataType - | SpaceAccessDeniedDataType - | SpaceNotFoundDataType - | SpaceOwnerNotFoundDataType - | SubscriptionAlreadyExistsDataType - | SubscriptionNotExistsDataType - | SubscriptionPlanNotFoundDataType - | UnknownOauthProviderDataType - | UnsupportedSubscriptionPlanDataType - | VersionRejectedDataType - | WrongSignInCredentialsDataType - -enum ErrorNames { - ACCESS_DENIED - - ACTION_FORBIDDEN - - ALREADY_IN_SPACE - - AUTHENTICATION_REQUIRED - - BLOB_NOT_FOUND - - BLOB_QUOTA_EXCEEDED - - CANNOT_DELETE_ALL_ADMIN_ACCOUNT - - CANNOT_DELETE_OWN_ACCOUNT - - CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION - - CAPTCHA_VERIFICATION_FAILED - - COPILOT_ACTION_TAKEN - - COPILOT_FAILED_TO_CREATE_MESSAGE - - COPILOT_FAILED_TO_GENERATE_TEXT - - COPILOT_MESSAGE_NOT_FOUND - - COPILOT_PROMPT_INVALID - - COPILOT_PROMPT_NOT_FOUND - - COPILOT_PROVIDER_SIDE_ERROR - - COPILOT_QUOTA_EXCEEDED - - COPILOT_SESSION_DELETED - - COPILOT_SESSION_NOT_FOUND - - CUSTOMER_PORTAL_CREATE_FAILED - - DOC_ACTION_DENIED - - DOC_HISTORY_NOT_FOUND - - DOC_NOT_FOUND - - EARLY_ACCESS_REQUIRED - - EMAIL_ALREADY_USED - - EMAIL_TOKEN_NOT_FOUND - - EMAIL_VERIFICATION_REQUIRED - - EXPECT_TO_PUBLISH_PAGE - - EXPECT_TO_REVOKE_PUBLIC_PAGE - - FAILED_TO_CHECKOUT - - FAILED_TO_SAVE_UPDATES - - FAILED_TO_UPSERT_SNAPSHOT - - INTERNAL_SERVER_ERROR - - INVALID_CHECKOUT_PARAMETERS - - INVALID_EMAIL - - INVALID_EMAIL_TOKEN - - INVALID_HISTORY_TIMESTAMP - - INVALID_OAUTH_CALLBACK_STATE - - INVALID_PASSWORD_LENGTH - - INVALID_RUNTIME_CONFIG_TYPE - - INVALID_SUBSCRIPTION_PARAMETERS - - LINK_EXPIRED - - MAILER_SERVICE_IS_NOT_CONFIGURED - - MEMBER_NOT_FOUND_IN_SPACE - - MEMBER_QUOTA_EXCEEDED - - MISSING_OAUTH_QUERY_PARAMETER - - NOT_FOUND - - NOT_IN_SPACE - - NO_COPILOT_PROVIDER_AVAILABLE - - OAUTH_ACCOUNT_ALREADY_CONNECTED - - OAUTH_STATE_EXPIRED - - PAGE_IS_NOT_PUBLIC - - PASSWORD_REQUIRED - - RUNTIME_CONFIG_NOT_FOUND - - SAME_EMAIL_PROVIDED - - SAME_SUBSCRIPTION_RECURRING - - SIGN_UP_FORBIDDEN - - SPACE_ACCESS_DENIED - - SPACE_NOT_FOUND - - SPACE_OWNER_NOT_FOUND - - SUBSCRIPTION_ALREADY_EXISTS - - SUBSCRIPTION_EXPIRED - - SUBSCRIPTION_HAS_BEEN_CANCELED - - SUBSCRIPTION_HAS_NOT_BEEN_CANCELED - - SUBSCRIPTION_NOT_EXISTS - - SUBSCRIPTION_PLAN_NOT_FOUND - - TOO_MANY_REQUEST - - UNKNOWN_OAUTH_PROVIDER - - UNSPLASH_IS_NOT_CONFIGURED - - UNSUPPORTED_SUBSCRIPTION_PLAN - - USER_AVATAR_NOT_FOUND - - USER_NOT_FOUND - - VERSION_REJECTED - - WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION - - WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION - - WRONG_SIGN_IN_CREDENTIALS - - WRONG_SIGN_IN_METHOD -} - -""" -The type of workspace feature -""" -enum FeatureType { - AIEarlyAccess - - Admin - - Copilot - - EarlyAccess - - UnlimitedCopilot - - UnlimitedWorkspace -} - -input ForkChatSessionInput { - docId: String! - - """ - Identify a message in the array and keep it with all previous messages into a forked session. - """ - latestMessageId: String! - - sessionId: String! - - workspaceId: String! -} - -type HumanReadableQuotaType { - blobLimit: String! - - copilotActionLimit: String - - historyPeriod: String! - - memberLimit: String! - - name: String! - - storageQuota: String! -} - -type InvalidEmailDataType { - email: String! -} - -type InvalidHistoryTimestampDataType { - timestamp: String! -} - -type InvalidPasswordLengthDataType { - max: Int! - - min: Int! -} - -type InvalidRuntimeConfigTypeDataType { - get: String! - - key: String! - - want: String! -} - -type InvitationType { - """ - Invitee information - """ - invitee: UserType! - - """ - User information - """ - user: UserType! - - """ - Workspace information - """ - workspace: InvitationWorkspaceType! -} - -type InvitationWorkspaceType { - """ - Base64 encoded avatar - """ - avatar: String! - - id: ID! - - """ - Workspace name - """ - name: String! -} - -type InviteLink { - """ - Invite link expire time - """ - expireTime: DateTime! - - """ - Invite link - """ - link: String! -} - -type InviteResult { - email: String! - - """ - Invite id, null if invite record create failed - """ - inviteId: String - - """ - Invite email sent success - """ - sentSuccess: Boolean! -} - -type InviteUserType { - """ - User accepted - """ - accepted: Boolean! @deprecated(reason: "Use `status` instead") - - """ - User avatar url - """ - avatarUrl: String - - """ - User email verified - """ - createdAt: DateTime @deprecated(reason: "useless") - - """ - User email - """ - email: String - - """ - User email verified - """ - emailVerified: Boolean - - """ - User password has been set - """ - hasPassword: Boolean - - id: ID! - - """ - Invite id - """ - inviteId: String! - - """ - User name - """ - name: String - - """ - User permission in workspace - """ - permission: Permission! - - """ - Member invite status in workspace - """ - status: WorkspaceMemberStatus! -} - -enum InvoiceStatus { - Draft - - Open - - Paid - - Uncollectible - - Void -} - -type InvoiceType { - amount: Int! - - createdAt: DateTime! - - currency: String! - - id: String @deprecated(reason: "removed") - - lastPaymentError: String - - link: String - - plan: SubscriptionPlan @deprecated(reason: "removed") - - reason: String! - - recurring: SubscriptionRecurring @deprecated(reason: "removed") - - status: InvoiceStatus! - - updatedAt: DateTime! -} - -""" -The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSON - -""" -The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). -""" -scalar JSONObject - -type LimitedUserType { - """ - User email - """ - email: String! - - """ - User password has been set - """ - hasPassword: Boolean -} - -input ListUserInput { - first: Int = 20 - - skip: Int = 0 -} - -type ListedBlob { - createdAt: String! - - key: String! - - mime: String! - - size: Int! -} - -input ManageUserInput { - """ - User email - """ - email: String - - """ - User name - """ - name: String -} - -type MemberNotFoundInSpaceDataType { - spaceId: String! -} - -type MissingOauthQueryParameterDataType { - name: String! -} - -type Mutation { - acceptInviteById( - inviteId: String! - sendAcceptMail: Boolean - workspaceId: String! - ): Boolean! - - addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! - - approveMember(userId: String!, workspaceId: String!): String! - - cancelSubscription( - idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`") - plan: SubscriptionPlan = Pro - workspaceId: String - ): SubscriptionType! - - changeEmail(email: String!, token: String!): UserType! - - changePassword(newPassword: String!, token: String!, userId: String): Boolean! - - """ - Cleanup sessions - """ - cleanupCopilotSession(options: DeleteSessionInput!): [String!]! - - """ - Create change password url - """ - createChangePasswordUrl(callbackUrl: String!, userId: String!): String! - - """ - Create a subscription checkout link of stripe - """ - createCheckoutSession(input: CreateCheckoutSessionInput!): String! - - """ - Create a chat message - """ - createCopilotMessage(options: CreateChatMessageInput!): String! - - """ - Create a copilot prompt - """ - createCopilotPrompt(input: CreateCopilotPromptInput!): CopilotPromptType! - - """ - Create a chat session - """ - createCopilotSession(options: CreateChatSessionInput!): String! - - """ - Create a stripe customer portal to manage payment methods - """ - createCustomerPortal: String! - - createInviteLink( - expireTime: WorkspaceInviteLinkExpireTime! - workspaceId: String! - ): InviteLink! - - """ - Create a new user - """ - createUser(input: CreateUserInput!): UserType! - - """ - Create a new workspace - """ - createWorkspace(init: Upload): WorkspaceType! - - deleteAccount: DeleteAccount! - - deleteBlob( - hash: String @deprecated(reason: "use parameter [key]") - key: String - permanently: Boolean! = false - workspaceId: String! - ): Boolean! - - """ - Delete a user account - """ - deleteUser(id: String!): DeleteAccount! - - deleteWorkspace(id: String!): Boolean! - - """ - Create a chat session - """ - forkCopilotSession(options: ForkChatSessionInput!): String! - - grantMember( - permission: Permission! - userId: String! - workspaceId: String! - ): String! - - invite( - email: String! - permission: Permission @deprecated(reason: "never used") - sendInviteMail: Boolean - workspaceId: String! - ): String! - - inviteBatch( - emails: [String!]! - sendInviteMail: Boolean - workspaceId: String! - ): [InviteResult!]! - - leaveWorkspace( - sendLeaveMail: Boolean - workspaceId: String! - workspaceName: String @deprecated(reason: "no longer used") - ): Boolean! - - publishPage( - mode: PublicPageMode = Page - pageId: String! - workspaceId: String! - ): WorkspacePage! - - recoverDoc( - guid: String! - timestamp: DateTime! - workspaceId: String! - ): DateTime! - - releaseDeletedBlobs(workspaceId: String!): Boolean! - - """ - Remove user avatar - """ - removeAvatar: RemoveAvatar! - - removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! - - resumeSubscription( - idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`") - plan: SubscriptionPlan = Pro - workspaceId: String - ): SubscriptionType! - - revoke(userId: String!, workspaceId: String!): Boolean! - - revokeInviteLink(workspaceId: String!): Boolean! - - revokePage(pageId: String!, workspaceId: String!): Boolean! - @deprecated(reason: "use revokePublicPage") - - revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage! - - sendChangeEmail(callbackUrl: String!, email: String): Boolean! - - sendChangePasswordEmail( - callbackUrl: String! - email: String @deprecated(reason: "fetched from signed in user") - ): Boolean! - - sendSetPasswordEmail( - callbackUrl: String! - email: String @deprecated(reason: "fetched from signed in user") - ): Boolean! - - sendVerifyChangeEmail( - callbackUrl: String! - email: String! - token: String! - ): Boolean! - - sendVerifyEmail(callbackUrl: String!): Boolean! - - setBlob(blob: Upload!, workspaceId: String!): String! - - setWorkspaceExperimentalFeature( - enable: Boolean! - feature: FeatureType! - workspaceId: String! - ): Boolean! - - sharePage(pageId: String!, workspaceId: String!): Boolean! - @deprecated(reason: "renamed to publishPage") - - """ - Update a copilot prompt - """ - updateCopilotPrompt( - messages: [CopilotPromptMessageInput!]! - name: String! - ): CopilotPromptType! - - updateProfile(input: UpdateUserInput!): UserType! - - """ - update server runtime configurable setting - """ - updateRuntimeConfig(id: String!, value: JSON!): ServerRuntimeConfigType! - - """ - update multiple server runtime configurable settings - """ - updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]! - - updateSubscriptionRecurring( - idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`") - plan: SubscriptionPlan = Pro - recurring: SubscriptionRecurring! - workspaceId: String - ): SubscriptionType! - - """ - Update a user - """ - updateUser(id: String!, input: ManageUserInput!): UserType! - - """ - update user enabled feature - """ - updateUserFeatures(features: [FeatureType!]!, id: String!): [FeatureType!]! - - """ - Update workspace - """ - updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType! - - """ - Upload user avatar - """ - uploadAvatar(avatar: Upload!): UserType! - - verifyEmail(token: String!): Boolean! -} - -type NotInSpaceDataType { - spaceId: String! -} - -enum OAuthProviderType { - GitHub - - Google - - OIDC -} - -type PasswordLimitsType { - maxLength: Int! - - minLength: Int! -} - -""" -User permission in workspace -""" -enum Permission { - Admin - - Owner - - Read - - Write -} - -""" -The mode which the public page default in -""" -enum PublicPageMode { - Edgeless - - Page -} - -type Query { - collectAllBlobSizes: WorkspaceBlobSizes! - @deprecated(reason: "use `user.quotaUsage` instead") - - """ - Get current user - """ - currentUser: UserType - - error(name: ErrorNames!): ErrorDataUnion! - - """ - send workspace invitation - """ - getInviteInfo(inviteId: String!): InvitationType! - - """ - Get is admin of workspace - """ - isAdmin(workspaceId: String!): Boolean! - - """ - Get is owner of workspace - """ - isOwner(workspaceId: String!): Boolean! - - """ - List blobs of workspace - """ - listBlobs(workspaceId: String!): [String!]! - @deprecated(reason: "use `workspace.blobs` instead") - - """ - List all copilot prompts - """ - listCopilotPrompts: [CopilotPromptType!]! - - listWorkspaceFeatures(feature: FeatureType!): [WorkspaceType!]! - - prices: [SubscriptionPrice!]! - - """ - server config - """ - serverConfig: ServerConfigType! - - """ - get all server runtime configurable settings - """ - serverRuntimeConfig: [ServerRuntimeConfigType!]! - - serverServiceConfigs: [ServerServiceConfig!]! - - """ - Get user by email - """ - user(email: String!): UserOrLimitedUser - - """ - Get user by email for admin - """ - userByEmail(email: String!): UserType - - """ - Get user by id - """ - userById(id: String!): UserType! - - """ - List registered users - """ - users(filter: ListUserInput!): [UserType!]! - - """ - Get users count - """ - usersCount: Int! - - """ - Get workspace by id - """ - workspace(id: String!): WorkspaceType! - - """ - Get all accessible workspaces for current user - """ - workspaces: [WorkspaceType!]! -} - -input QueryChatHistoriesInput { - action: Boolean - - fork: Boolean - - limit: Int - - messageOrder: ChatHistoryOrder - - sessionId: String - - sessionOrder: ChatHistoryOrder - - skip: Int - - withPrompt: Boolean -} - -type QuotaQueryType { - blobLimit: SafeInt! - - copilotActionLimit: SafeInt - - historyPeriod: SafeInt! - - humanReadable: HumanReadableQuotaType! - - memberCount: SafeInt! - - memberLimit: SafeInt! - - name: String! - - storageQuota: SafeInt! - - usedSize: SafeInt! -} - -type RemoveAvatar { - success: Boolean! -} - -type RuntimeConfigNotFoundDataType { - key: String! -} - -enum RuntimeConfigType { - Array - - Boolean - - Number - - Object - - String -} - -""" -The `SafeInt` scalar type represents non-fractional signed whole numeric values that are considered safe as defined by the ECMAScript specification. -""" -scalar SafeInt - -type SameSubscriptionRecurringDataType { - recurring: String! -} - -type ServerConfigType { - """ - Features for user that can be configured - """ - availableUserFeatures: [FeatureType!]! - - """ - server base url - """ - baseUrl: String! - - """ - credentials requirement - """ - credentialsRequirement: CredentialsRequirementType! - - """ - enable telemetry - """ - enableTelemetry: Boolean! - - """ - enabled server features - """ - features: [ServerFeature!]! - - """ - server flags - """ - flags: ServerFlagsType! - - """ - server flavor - """ - flavor: String! @deprecated(reason: "use `features`") - - """ - whether server has been initialized - """ - initialized: Boolean! - - """ - server identical name could be shown as badge on user interface - """ - name: String! - - oauthProviders: [OAuthProviderType!]! - - """ - server type - """ - type: ServerDeploymentType! - - """ - server version - """ - version: String! -} - -enum ServerDeploymentType { - Affine - - Selfhosted -} - -enum ServerFeature { - Captcha - - Copilot - - OAuth - - Payment -} - -type ServerFlagsType { - earlyAccessControl: Boolean! - - syncClientVersionCheck: Boolean! -} - -type ServerRuntimeConfigType { - description: String! - - id: String! - - key: String! - - module: String! - - type: RuntimeConfigType! - - updatedAt: DateTime! - - value: JSON! -} - -type ServerServiceConfig { - config: JSONObject! - - name: String! -} - -type SpaceAccessDeniedDataType { - spaceId: String! -} - -type SpaceNotFoundDataType { - spaceId: String! -} - -type SpaceOwnerNotFoundDataType { - spaceId: String! -} - -type SubscriptionAlreadyExistsDataType { - plan: String! -} - -type SubscriptionNotExistsDataType { - plan: String! -} - -enum SubscriptionPlan { - AI - - Enterprise - - Free - - Pro - - SelfHosted - - Team -} - -type SubscriptionPlanNotFoundDataType { - plan: String! - - recurring: String! -} - -type SubscriptionPrice { - amount: Int - - currency: String! - - lifetimeAmount: Int - - plan: SubscriptionPlan! - - type: String! - - yearlyAmount: Int -} - -enum SubscriptionRecurring { - Lifetime - - Monthly - - Yearly -} - -enum SubscriptionStatus { - Active - - Canceled - - Incomplete - - IncompleteExpired - - PastDue - - Paused - - Trialing - - Unpaid -} - -type SubscriptionType { - canceledAt: DateTime - - createdAt: DateTime! - - end: DateTime - - id: String @deprecated(reason: "removed") - - nextBillAt: DateTime - - """ - The 'Free' plan just exists to be a placeholder and for the type convenience of frontend. - There won't actually be a subscription with plan 'Free' - """ - plan: SubscriptionPlan! - - recurring: SubscriptionRecurring! - - start: DateTime! - - status: SubscriptionStatus! - - trialEnd: DateTime - - trialStart: DateTime - - updatedAt: DateTime! - - variant: SubscriptionVariant -} - -enum SubscriptionVariant { - EA - - Onetime -} - -type UnknownOauthProviderDataType { - name: String! -} - -type UnsupportedSubscriptionPlanDataType { - plan: String! -} - -input UpdateUserInput { - """ - User name - """ - name: String -} - -input UpdateWorkspaceInput { - """ - Enable AI - """ - enableAi: Boolean - - """ - Enable url previous when sharing - """ - enableUrlPreview: Boolean - - id: ID! - - """ - is Public workspace - """ - public: Boolean -} - -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - -union UserOrLimitedUser = LimitedUserType | UserType - -type UserQuota { - blobLimit: SafeInt! - - historyPeriod: SafeInt! - - humanReadable: UserQuotaHumanReadable! - - memberLimit: Int! - - name: String! - - storageQuota: SafeInt! -} - -type UserQuotaHumanReadable { - blobLimit: String! - - historyPeriod: String! - - memberLimit: String! - - name: String! - - storageQuota: String! -} - -type UserQuotaUsage { - storageQuota: SafeInt! -} - -type UserType { - """ - User avatar url - """ - avatarUrl: String - - copilot(workspaceId: String): Copilot! - - """ - User email verified - """ - createdAt: DateTime @deprecated(reason: "useless") - - """ - User email - """ - email: String! - - """ - User email verified - """ - emailVerified: Boolean! - - """ - Enabled features of a user - """ - features: [FeatureType!]! - - """ - User password has been set - """ - hasPassword: Boolean - - id: ID! - - """ - Get user invoice count - """ - invoiceCount: Int! - - invoices(skip: Int, take: Int = 8): [InvoiceType!]! - - """ - User name - """ - name: String! - - quota: UserQuota - - quotaUsage: UserQuotaUsage! - - subscriptions: [SubscriptionType!]! - - token: tokenType! - @deprecated(reason: "use [/api/auth/sign-in?native=true] instead") -} - -type VersionRejectedDataType { - serverVersion: String! - - version: String! -} - -type WorkspaceBlobSizes { - size: SafeInt! -} - -""" -Workspace invite link expire time -""" -enum WorkspaceInviteLinkExpireTime { - OneDay - - OneMonth - - OneWeek - - ThreeDays -} - -""" -Member invite status in workspace -""" -enum WorkspaceMemberStatus { - Accepted - - NeedMoreSeat - - NeedMoreSeatAndReview - - Pending - - UnderReview -} - -type WorkspacePage { - id: String! - - mode: PublicPageMode! - - public: Boolean! - - workspaceId: String! -} - -type WorkspacePageMeta { - createdAt: DateTime! - - createdBy: EditorType - - updatedAt: DateTime! - - updatedBy: EditorType -} - -type WorkspaceType { - """ - Available features of workspace - """ - availableFeatures: [FeatureType!]! - - """ - List blobs of workspace - """ - blobs: [ListedBlob!]! - - """ - Blobs size of workspace - """ - blobsSize: Int! - - """ - Workspace created date - """ - createdAt: DateTime! - - """ - Enable AI - """ - enableAi: Boolean! - - """ - Enable url previous when sharing - """ - enableUrlPreview: Boolean! - - """ - Enabled features of workspace - """ - features: [FeatureType!]! - - histories(before: DateTime, guid: String!, take: Int): [DocHistoryType!]! - - id: ID! - - """ - is current workspace initialized - """ - initialized: Boolean! - - """ - invite link for workspace - """ - inviteLink: InviteLink - - """ - Get user invoice count - """ - invoiceCount: Int! - - invoices(skip: Int, take: Int = 8): [InvoiceType!]! - - """ - member count of workspace - """ - memberCount: Int! - - """ - Members of workspace - """ - members(skip: Int, take: Int): [InviteUserType!]! - - """ - Owner of workspace - """ - owner: UserType! - - """ - Cloud page metadata of workspace - """ - pageMeta(pageId: String!): WorkspacePageMeta! - - """ - Permission of current signed in user in workspace - """ - permission: Permission! - - """ - is Public workspace - """ - public: Boolean! - - """ - Get public page of a workspace by page id. - """ - publicPage(pageId: String!): WorkspacePage - - """ - Public pages of a workspace - """ - publicPages: [WorkspacePage!]! - - """ - quota of workspace - """ - quota: QuotaQueryType! - - """ - Shared pages of workspace - """ - sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") - - """ - The team subscription of the workspace, if exists. - """ - subscription: SubscriptionType - - """ - if workspace is team workspace - """ - team: Boolean! -} - -type WrongSignInCredentialsDataType { - email: String! -} - -""" -A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. - -In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor. -""" -type __Directive { - name: String! - - description: String - - isRepeatable: Boolean! - - locations: [__DirectiveLocation!]! - - args(includeDeprecated: Boolean = false): [__InputValue!]! -} - -""" -A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies. -""" -enum __DirectiveLocation { - """ - Location adjacent to a query operation. - """ - QUERY - - """ - Location adjacent to a mutation operation. - """ - MUTATION - - """ - Location adjacent to a subscription operation. - """ - SUBSCRIPTION - - """ - Location adjacent to a field. - """ - FIELD - - """ - Location adjacent to a fragment definition. - """ - FRAGMENT_DEFINITION - - """ - Location adjacent to a fragment spread. - """ - FRAGMENT_SPREAD - - """ - Location adjacent to an inline fragment. - """ - INLINE_FRAGMENT - - """ - Location adjacent to a variable definition. - """ - VARIABLE_DEFINITION - - """ - Location adjacent to a schema definition. - """ - SCHEMA - - """ - Location adjacent to a scalar definition. - """ - SCALAR - - """ - Location adjacent to an object type definition. - """ - OBJECT - - """ - Location adjacent to a field definition. - """ - FIELD_DEFINITION - - """ - Location adjacent to an argument definition. - """ - ARGUMENT_DEFINITION - - """ - Location adjacent to an interface definition. - """ - INTERFACE - - """ - Location adjacent to a union definition. - """ - UNION - - """ - Location adjacent to an enum definition. - """ - ENUM - - """ - Location adjacent to an enum value definition. - """ - ENUM_VALUE - - """ - Location adjacent to an input object type definition. - """ - INPUT_OBJECT - - """ - Location adjacent to an input object field definition. - """ - INPUT_FIELD_DEFINITION -} - -""" -One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string. -""" -type __EnumValue { - name: String! - - description: String - - isDeprecated: Boolean! - - deprecationReason: String -} - -""" -Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type. -""" -type __Field { - name: String! - - description: String - - args(includeDeprecated: Boolean = false): [__InputValue!]! - - type: __Type! - - isDeprecated: Boolean! - - deprecationReason: String -} - -""" -Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value. -""" -type __InputValue { - name: String! - - description: String - - type: __Type! - - """ - A GraphQL-formatted string representing the default value for this input value. - """ - defaultValue: String - - isDeprecated: Boolean! - - deprecationReason: String -} - -""" -A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations. -""" -type __Schema { - description: String - - """ - A list of all types supported by this server. - """ - types: [__Type!]! - - """ - The type that query operations will be rooted at. - """ - queryType: __Type! - - """ - If this server supports mutation, the type that mutation operations will be rooted at. - """ - mutationType: __Type - - """ - If this server support subscription, the type that subscription operations will be rooted at. - """ - subscriptionType: __Type - - """ - A list of all directives supported by this server. - """ - directives: [__Directive!]! -} - -""" -The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum. - -Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional `specifiedByURL`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. -""" -type __Type { - kind: __TypeKind! - - name: String - - description: String - - specifiedByURL: String - - fields(includeDeprecated: Boolean = false): [__Field!] - - interfaces: [__Type!] - - possibleTypes: [__Type!] - - enumValues(includeDeprecated: Boolean = false): [__EnumValue!] - - inputFields(includeDeprecated: Boolean = false): [__InputValue!] - - ofType: __Type - - isOneOf: Boolean -} - -""" -An enum describing what kind of type a given `__Type` is. -""" -enum __TypeKind { - """ - Indicates this type is a scalar. - """ - SCALAR - - """ - Indicates this type is an object. `fields` and `interfaces` are valid fields. - """ - OBJECT - - """ - Indicates this type is an interface. `fields`, `interfaces`, and `possibleTypes` are valid fields. - """ - INTERFACE - - """ - Indicates this type is a union. `possibleTypes` is a valid field. - """ - UNION - - """ - Indicates this type is an enum. `enumValues` is a valid field. - """ - ENUM - - """ - Indicates this type is an input object. `inputFields` is a valid field. - """ - INPUT_OBJECT - - """ - Indicates this type is a list. `ofType` is a valid field. - """ - LIST - - """ - Indicates this type is a non-null. `ofType` is a valid field. - """ - NON_NULL -} - -type tokenType { - refresh: String! - - sessionToken: String - - token: String! -} - -""" -Marks an element of a GraphQL schema as no longer supported. -""" -directive @deprecated( - "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/)." - reason: String = "No longer supported" -) on ARGUMENT_DEFINITION | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION - -""" -Directs the executor to include this field or fragment only when the `if` argument is true. -""" -directive @include( - "Included when true." - if: Boolean! -) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - -""" -Indicates exactly one field must be supplied and this field must not be `null`. -""" -directive @oneOf on INPUT_OBJECT - -""" -Directs the executor to skip this field or fragment when the `if` argument is true. -""" -directive @skip( - "Skipped when true." - if: Boolean! -) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT - -""" -Exposes a URL that specifies the behavior of this scalar. -""" -directive @specifiedBy( - "The URL that specifies the behavior of this scalar." - url: String! -) on SCALAR - -schema { - query: Query - mutation: Mutation -} diff --git a/packages/frontend/apps/android/App/variables.gradle b/packages/frontend/apps/android/App/variables.gradle index 5eb63f6959..38d321bbf9 100644 --- a/packages/frontend/apps/android/App/variables.gradle +++ b/packages/frontend/apps/android/App/variables.gradle @@ -1,5 +1,6 @@ ext { - minSdkVersion = 22 - compileSdkVersion = 35 - targetSdkVersion = 35 + minSdkVersion = libs.versions.minSdk.get().toInteger() + compileSdkVersion = libs.versions.compileSdk.get().toInteger() + targetSdkVersion = libs.versions.targetSdk.get().toInteger() + kotlin_version = libs.versions.kotlin.get() } \ No newline at end of file diff --git a/packages/frontend/apps/android/capacitor.config.ts b/packages/frontend/apps/android/capacitor.config.ts index f1c73e4311..bd7d302f33 100644 --- a/packages/frontend/apps/android/capacitor.config.ts +++ b/packages/frontend/apps/android/capacitor.config.ts @@ -1,11 +1,21 @@ -import { join } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import type { CapacitorConfig } from '@capacitor/cli'; -const config: CapacitorConfig = { +const packageJson = JSON.parse( + readFileSync(resolve(__dirname, './package.json'), 'utf-8') +); + +interface AppConfig { + affineVersion: string; +} + +const config: CapacitorConfig & AppConfig = { appId: 'app.affine.pro', appName: 'AFFiNE', webDir: 'dist', + affineVersion: packageJson.version, android: { path: 'App', buildOptions: { @@ -16,14 +26,25 @@ const config: CapacitorConfig = { releaseType: 'AAB', }, }, + server: { + cleartext: true, + }, plugins: { CapacitorHttp: { - enabled: true, + enabled: false, }, CapacitorCookies: { - enabled: true, + enabled: false, }, }, }; +if (process.env.CAP_SERVER_URL) { + Object.assign(config, { + server: { + url: process.env.CAP_SERVER_URL, + }, + }); +} + export default config; diff --git a/packages/frontend/apps/android/package.json b/packages/frontend/apps/android/package.json index 669b6530b5..19b920b0f9 100644 --- a/packages/frontend/apps/android/package.json +++ b/packages/frontend/apps/android/package.json @@ -6,6 +6,8 @@ "scripts": { "build": "affine bundle", "dev": "affine bundle --dev", + "sync": "cap sync", + "sync:dev": "CAP_SERVER_URL=http://10.0.2.2:8080 cap sync", "studio": "cap open android" }, "dependencies": { @@ -25,6 +27,8 @@ "@capgo/inappbrowser": "^7.1.0", "@sentry/react": "^9.2.0", "@toeverything/infra": "workspace:*", + "async-call-rpc": "^6.4.2", + "idb": "^8.0.0", "next-themes": "^0.4.4", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/frontend/apps/android/src/app.tsx b/packages/frontend/apps/android/src/app.tsx index a5f10231b4..f50f24ba99 100644 --- a/packages/frontend/apps/android/src/app.tsx +++ b/packages/frontend/apps/android/src/app.tsx @@ -6,9 +6,13 @@ import { router } from '@affine/core/mobile/router'; import { configureCommonModules } from '@affine/core/modules'; import { AIButtonProvider } from '@affine/core/modules/ai-button'; import { + AuthProvider, AuthService, DefaultServerService, + ServerScope, + ServerService, ServersService, + ValidatorProvider, } from '@affine/core/modules/cloud'; import { DocsService } from '@affine/core/modules/doc'; import { GlobalContextService } from '@affine/core/modules/global-context'; @@ -40,16 +44,19 @@ import { EdgeToEdge } from '@capawesome/capacitor-android-edge-to-edge-support'; import { InAppBrowser } from '@capgo/inappbrowser'; import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra'; import { OpClient } from '@toeverything/infra/op'; +import { AsyncCall } from 'async-call-rpc'; import { useTheme } from 'next-themes'; import { Suspense, useEffect } from 'react'; import { RouterProvider } from 'react-router-dom'; import { AffineTheme } from './plugins/affine-theme'; import { AIButton } from './plugins/ai-button'; +import { Auth } from './plugins/auth'; +import { HashCash } from './plugins/hashcash'; +import { NbStoreNativeDBApis } from './plugins/nbstore'; +import { writeEndpointToken } from './proxy'; -const storeManagerClient = new StoreManagerClient( - new OpClient(new Worker(getWorkerUrl('nbstore'))) -); +const storeManagerClient = createStoreManagerClient(); window.addEventListener('beforeunload', () => { storeManagerClient.dispose(); }); @@ -137,6 +144,13 @@ framework.impl(VirtualKeyboardProvider, { }, }); +framework.impl(ValidatorProvider, { + async validate(_challenge, resource) { + const res = await HashCash.hash({ challenge: resource }); + return res.value; + }, +}); + framework.impl(AIButtonProvider, { presentAIButton: () => { return AIButton.present(); @@ -146,6 +160,44 @@ framework.impl(AIButtonProvider, { }, }); +framework.scope(ServerScope).override(AuthProvider, resolver => { + const serverService = resolver.get(ServerService); + const endpoint = serverService.server.baseUrl; + return { + async signInMagicLink(email, linkToken, clientNonce) { + const { token } = await Auth.signInMagicLink({ + endpoint, + email, + token: linkToken, + clientNonce, + }); + await writeEndpointToken(endpoint, token); + }, + async signInOauth(code, state, _provider, clientNonce) { + const { token } = await Auth.signInOauth({ + endpoint, + code, + state, + clientNonce, + }); + await writeEndpointToken(endpoint, token); + return {}; + }, + async signInPassword(credential) { + const { token } = await Auth.signInPassword({ + endpoint, + ...credential, + }); + await writeEndpointToken(endpoint, token); + }, + async signOut() { + await Auth.signOut({ + endpoint, + }); + }, + }; +}); + // ------ some apis for native ------ (window as any).getCurrentServerBaseUrl = () => { const globalContextService = frameworkProvider.get(GlobalContextService); @@ -302,3 +354,35 @@ export function App() { ); } + +function createStoreManagerClient() { + const worker = new Worker(getWorkerUrl('nbstore.worker.js')); + const { port1: nativeDBApiChannelServer, port2: nativeDBApiChannelClient } = + new MessageChannel(); + AsyncCall(NbStoreNativeDBApis, { + channel: { + on(listener) { + const f = (e: MessageEvent) => { + 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)); +} diff --git a/packages/frontend/apps/android/src/index.tsx b/packages/frontend/apps/android/src/index.tsx index 327f1ce7d1..e43bfce783 100644 --- a/packages/frontend/apps/android/src/index.tsx +++ b/packages/frontend/apps/android/src/index.tsx @@ -1,10 +1,14 @@ import './setup'; import { Telemetry } from '@affine/core/components/telemetry'; +import { bindNativeDBApis } from '@affine/nbstore/sqlite'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './app'; +import { NbStoreNativeDBApis } from './plugins/nbstore'; + +bindNativeDBApis(NbStoreNativeDBApis); function mountApp() { // oxlint-disable-next-line no-non-null-assertion diff --git a/packages/frontend/apps/android/src/nbstore.worker.ts b/packages/frontend/apps/android/src/nbstore.worker.ts index 30b53ac08e..f2e2d93b82 100644 --- a/packages/frontend/apps/android/src/nbstore.worker.ts +++ b/packages/frontend/apps/android/src/nbstore.worker.ts @@ -1,22 +1,71 @@ -import '@affine/core/bootstrap/browser'; +import './setup-worker'; import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel'; -import { cloudStorages } from '@affine/nbstore/cloud'; -import { idbStorages } from '@affine/nbstore/idb'; +import { + cloudStorages, + configureSocketAuthMethod, +} from '@affine/nbstore/cloud'; +import { idbStoragesIndexerOnly } from '@affine/nbstore/idb'; +import { + bindNativeDBApis, + type NativeDBApis, + sqliteStorages, +} from '@affine/nbstore/sqlite'; import { StoreManagerConsumer, type WorkerManagerOps, } from '@affine/nbstore/worker/consumer'; import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op'; +import { AsyncCall } from 'async-call-rpc'; -const consumer = new StoreManagerConsumer([ - ...idbStorages, +import { readEndpointToken } from './proxy'; + +configureSocketAuthMethod((endpoint, cb) => { + readEndpointToken(endpoint) + .then(token => { + cb({ token }); + }) + .catch(e => { + console.error(e); + }); +}); + +globalThis.addEventListener('message', e => { + if (e.data.type === 'native-db-api-channel') { + const port = e.ports[0] as MessagePort; + const rpc = AsyncCall( + {}, + { + channel: { + on(listener) { + const f = (e: MessageEvent) => { + 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( + globalThis as MessageCommunicapable +); + +const storeManager = new StoreManagerConsumer([ + ...idbStoragesIndexerOnly, + ...sqliteStorages, ...broadcastChannelStorages, ...cloudStorages, ]); -const opConsumer = new OpConsumer( - globalThis as MessageCommunicapable -); - -consumer.bindConsumer(opConsumer); +storeManager.bindConsumer(consumer); diff --git a/packages/frontend/apps/android/src/plugins/auth/definitions.ts b/packages/frontend/apps/android/src/plugins/auth/definitions.ts new file mode 100644 index 0000000000..8e5ffae4ad --- /dev/null +++ b/packages/frontend/apps/android/src/plugins/auth/definitions.ts @@ -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; +} diff --git a/packages/frontend/apps/android/src/plugins/auth/index.ts b/packages/frontend/apps/android/src/plugins/auth/index.ts new file mode 100644 index 0000000000..460cf98951 --- /dev/null +++ b/packages/frontend/apps/android/src/plugins/auth/index.ts @@ -0,0 +1,8 @@ +import { registerPlugin } from '@capacitor/core'; + +import type { AuthPlugin } from './definitions'; + +const Auth = registerPlugin('Auth'); + +export * from './definitions'; +export { Auth }; diff --git a/packages/frontend/apps/android/src/plugins/hashcash/definitions.ts b/packages/frontend/apps/android/src/plugins/hashcash/definitions.ts new file mode 100644 index 0000000000..92bc8aba94 --- /dev/null +++ b/packages/frontend/apps/android/src/plugins/hashcash/definitions.ts @@ -0,0 +1,6 @@ +export interface HashCashPlugin { + hash(options: { + challenge: string; + bits?: number; + }): Promise<{ value: string }>; +} diff --git a/packages/frontend/apps/android/src/plugins/hashcash/index.ts b/packages/frontend/apps/android/src/plugins/hashcash/index.ts new file mode 100644 index 0000000000..94bec7d555 --- /dev/null +++ b/packages/frontend/apps/android/src/plugins/hashcash/index.ts @@ -0,0 +1,8 @@ +import { registerPlugin } from '@capacitor/core'; + +import type { HashCashPlugin } from './definitions'; + +const HashCash = registerPlugin('HashCash'); + +export * from './definitions'; +export { HashCash }; diff --git a/packages/frontend/apps/android/src/plugins/nbstore/definitions.ts b/packages/frontend/apps/android/src/plugins/nbstore/definitions.ts new file mode 100644 index 0000000000..a90f271fe0 --- /dev/null +++ b/packages/frontend/apps/android/src/plugins/nbstore/definitions.ts @@ -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; + disconnect: (options: { id: string }) => Promise; + + setSpaceId: (options: { id: string; spaceId: string }) => Promise; + 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; + 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; + setBlob: (options: { id: string } & SetBlob) => Promise; + deleteBlob: (options: { + id: string; + key: string; + permanently: boolean; + }) => Promise; + releaseBlobs: (options: { id: string }) => Promise; + listBlobs: (options: { id: string }) => Promise<{ blobs: Array }>; + getPeerRemoteClocks: (options: { + id: string; + peer: string; + }) => Promise<{ clocks: Array }>; + getPeerRemoteClock: (options: { + id: string; + peer: string; + docId: string; + }) => Promise; + setPeerRemoteClock: (options: { + id: string; + peer: string; + docId: string; + timestamp: number; + }) => Promise; + getPeerPushedClocks: (options: { + id: string; + peer: string; + }) => Promise<{ clocks: Array }>; + getPeerPushedClock: (options: { + id: string; + peer: string; + docId: string; + }) => Promise; + setPeerPushedClock: (options: { + id: string; + peer: string; + docId: string; + timestamp: number; + }) => Promise; + getPeerPulledRemoteClocks: (options: { + id: string; + peer: string; + }) => Promise<{ clocks: Array }>; + getPeerPulledRemoteClock: (options: { + id: string; + peer: string; + docId: string; + }) => Promise; + setPeerPulledRemoteClock: (options: { + id: string; + peer: string; + docId: string; + timestamp: number; + }) => Promise; + getBlobUploadedAt: (options: { + id: string; + peer: string; + blobId: string; + }) => Promise<{ uploadedAt: number | null }>; + setBlobUploadedAt: (options: { + id: string; + peer: string; + blobId: string; + uploadedAt: number | null; + }) => Promise; + clearClocks: (options: { id: string }) => Promise; +} diff --git a/packages/frontend/apps/android/src/plugins/nbstore/index.ts b/packages/frontend/apps/android/src/plugins/nbstore/index.ts new file mode 100644 index 0000000000..4491240967 --- /dev/null +++ b/packages/frontend/apps/android/src/plugins/nbstore/index.ts @@ -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('NbStoreDocStorage'); + +export const NbStoreNativeDBApis: NativeDBApis = { + connect: async function (id: string): Promise { + const { peer, type, id: spaceId } = parseUniversalId(id); + return await NbStore.connect({ id, spaceId, spaceType: type, peer }); + }, + disconnect: function (id: string): Promise { + return NbStore.disconnect({ id }); + }, + pushUpdate: async function ( + id: string, + docId: string, + update: Uint8Array + ): Promise { + const { timestamp } = await NbStore.pushUpdate({ + id, + docId, + data: await uint8ArrayToBase64(update), + }); + return new Date(timestamp); + }, + getDocSnapshot: async function ( + id: string, + docId: string + ): Promise { + 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 { + 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 { + 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 { + const { count } = await NbStore.markUpdatesMerged({ + id, + docId, + timestamps: updates.map(t => t.getTime()), + }); + return count; + }, + deleteDoc: async function (id: string, docId: string): Promise { + await NbStore.deleteDoc({ + id, + docId, + }); + }, + getDocClocks: async function ( + id: string, + after?: Date | undefined | null + ): Promise { + 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 { + 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 { + 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 { + 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 { + await NbStore.deleteBlob({ + id, + key, + permanently, + }); + }, + releaseBlobs: async function (id: string): Promise { + await NbStore.releaseBlobs({ + id, + }); + }, + listBlobs: async function (id: string): Promise { + 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 { + 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 { + await NbStore.setPeerRemoteClock({ + id, + peer, + docId, + timestamp: clock.getTime(), + }); + }, + getPeerPulledRemoteClocks: async function ( + id: string, + peer: string + ): Promise { + 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 { + await NbStore.setPeerPulledRemoteClock({ + id, + peer, + docId, + timestamp: clock.getTime(), + }); + }, + getPeerPushedClocks: async function ( + id: string, + peer: string + ): Promise { + 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 { + 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 { + await NbStore.setPeerPushedClock({ + id, + peer, + docId, + timestamp: clock.getTime(), + }); + }, + clearClocks: async function (id: string): Promise { + await NbStore.clearClocks({ + id, + }); + }, + getBlobUploadedAt: async function ( + id: string, + peer: string, + blobId: string + ): Promise { + 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 { + await NbStore.setBlobUploadedAt({ + id, + peer, + blobId, + uploadedAt: uploadedAt ? uploadedAt.getTime() : null, + }); + }, +}; diff --git a/packages/frontend/apps/android/src/proxy.ts b/packages/frontend/apps/android/src/proxy.ts new file mode 100644 index 0000000000..396dad354b --- /dev/null +++ b/packages/frontend/apps/android/src/proxy.ts @@ -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 { + 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 }); +} diff --git a/packages/frontend/apps/android/src/setup-worker.ts b/packages/frontend/apps/android/src/setup-worker.ts new file mode 100644 index 0000000000..baefae9fa5 --- /dev/null +++ b/packages/frontend/apps/android/src/setup-worker.ts @@ -0,0 +1,2 @@ +import '@affine/core/bootstrap/browser'; +import './proxy'; diff --git a/packages/frontend/apps/android/src/setup.ts b/packages/frontend/apps/android/src/setup.ts index 64569a71e2..7032e81feb 100644 --- a/packages/frontend/apps/android/src/setup.ts +++ b/packages/frontend/apps/android/src/setup.ts @@ -2,3 +2,4 @@ import '@affine/core/bootstrap/browser'; import '@affine/core/bootstrap/blocksuite'; import '@affine/component/theme'; import '@affine/core/mobile/styles/mobile.css'; +import './proxy'; diff --git a/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts b/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts index 89f082f9a4..bb095b0986 100644 --- a/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts +++ b/packages/frontend/core/src/blocksuite/extensions/entry/enable-mobile.ts @@ -92,17 +92,20 @@ function KeyboardToolbarExtension(framework: FrameworkProvider): ExtensionType { } if ('show' in affineVirtualKeyboardProvider) { - return class BSVirtualKeyboardWithActionService + const providerWithAction = affineVirtualKeyboardProvider; + class BSVirtualKeyboardServiceWithShowAndHide extends BSVirtualKeyboardService implements VirtualKeyboardProviderWithAction { show() { - affineVirtualKeyboardProvider.show(); + providerWithAction.show(); } hide() { - affineVirtualKeyboardProvider.hide(); + providerWithAction.hide(); } - }; + } + + return BSVirtualKeyboardServiceWithShowAndHide; } return BSVirtualKeyboardService; diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts index 586777f2a3..973862ba39 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts @@ -97,7 +97,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { readonly flavour = this.server.id; DocStorageType = - BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS + BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid ? SqliteDocStorage : IndexedDBDocStorage; DocStorageV1Type = BUILD_CONFIG.isElectron @@ -106,7 +106,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ? IndexedDBV1DocStorage : undefined; BlobStorageType = - BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS + BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid ? SqliteBlobStorage : IndexedDBBlobStorage; BlobStorageV1Type = BUILD_CONFIG.isElectron @@ -115,11 +115,11 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ? IndexedDBV1BlobStorage : undefined; DocSyncStorageType = - BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS + BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid ? SqliteDocSyncStorage : IndexedDBDocSyncStorage; BlobSyncStorageType = - BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS + BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid ? SqliteBlobSyncStorage : IndexedDBBlobSyncStorage; diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/local.ts b/packages/frontend/core/src/modules/workspace-engine/impls/local.ts index cebdbb352d..f46f13ded7 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/local.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/local.ts @@ -82,7 +82,7 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ); DocStorageType = - BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS + BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid ? SqliteDocStorage : IndexedDBDocStorage; DocStorageV1Type = BUILD_CONFIG.isElectron @@ -91,7 +91,7 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ? IndexedDBV1DocStorage : undefined; BlobStorageType = - BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS + BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid ? SqliteBlobStorage : IndexedDBBlobStorage; BlobStorageV1Type = BUILD_CONFIG.isElectron @@ -100,11 +100,11 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider { ? IndexedDBV1BlobStorage : undefined; DocSyncStorageType = - BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS + BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid ? SqliteDocSyncStorage : IndexedDBDocSyncStorage; BlobSyncStorageType = - BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS + BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS || BUILD_CONFIG.isAndroid ? SqliteBlobSyncStorage : IndexedDBBlobSyncStorage; diff --git a/yarn.lock b/yarn.lock index 75ea7fcf95..14cd92610d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -260,7 +260,9 @@ __metadata: "@toeverything/infra": "workspace:*" "@types/react": "npm:^19.0.1" "@types/react-dom": "npm:^19.0.2" + async-call-rpc: "npm:^6.4.2" cross-env: "npm:^7.0.3" + idb: "npm:^8.0.0" next-themes: "npm:^0.4.4" react: "npm:^19.0.0" react-dom: "npm:^19.0.0"